Performance
ezz moves work out of the request path wherever it can. Most of it happens when you register a route, which is why registration order matters in a few places.
What happens at registration
| Work | When |
|---|---|
| Request binder and validator built | ezz.H(...) is called |
| Struct field metadata cached | First time a type is bound or validated |
| Middleware chain composed | Route registration |
Route pattern registered on ServeMux | Route registration |
| Request and response types recorded | Route registration |
By the time a request arrives, a typed handler already knows which fields to fill and which rules to run. The binding pass walks a cached field list rather than reflecting over the struct.
What happens per request
| Allocation | Handling |
|---|---|
*ezz.Context | Taken from a sync.Pool, reset, returned when the handler ends |
| Response writer wrapper | Pooled the same way |
| Query values | Parsed on first access, then cached for the request |
| Request metadata | Extracted once at context creation |
| Request ID bytes | Pooled buffer, crypto/rand, hex encoded |
| Validated JWT | Cached by token string in the HMAC provider |
| Resolved authorization | Computed at most once per request, optionally cached across requests |
Two rules follow from the pooling:
*ezz.Context past the end of your handler. It is reset and reused, so a goroutine holding one will read another request's data. Copy the values you need first. See Context.Use afterwards has no effect on it, and Logger rebuilds the default chain from scratch. See Middleware.Choices that cost you
A few habits undo the work above.
Building handlers per request. ezz.H compiles a binder and a validator each time it is called. Call it once, at registration.
// good
app.POST("/users", ezz.H(createUser))
// bad: rebuilds the binder on every request
app.POST("/users", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ezz.H(createUser).ServeHTTP(w, r)
}))
Resolving authorization on every request. A resolver that queries the database runs on each request that checks a role. Wrap it:
authz := auth.Cached(resolver{db: db}, 5*time.Minute)
Compressing tiny responses. Gzip has a 1 KB floor by default for a reason. Raising MinLength costs nothing; lowering it burns CPU on payloads that will not shrink.
Rate limiting with an unbounded key space. The in-memory limiters keep a bucket per key with a periodic sweep. Keying by something high-cardinality, such as a full URL, grows the map faster than the sweep clears it. Key by client, tenant, or route.
Benchmark your own routes
The framework's own benchmarks live next to the code and run with the standard tooling:
go test -bench=. -benchmem ./...
make bench # writes results under the benchmark directory
make bench-quick # a shorter run
make bench-compare # compare against a stored baseline
For your service, benchmark through the app so binding, validation, and auth are included:
func BenchmarkCreateUser(b *testing.B) {
app := buildApp(memStore())
body := []byte(`{"name":"Ada","email":"ada@example.com"}`)
token := "Bearer " + benchToken()
b.ReportAllocs()
b.ResetTimer()
for range b.N {
req := httptest.NewRequest("POST", "/api/v1/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", token)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
b.Fatalf("unexpected status %d", rec.Code)
}
}
}
httptest.NewRequest and NewRecorder allocate on every iteration, so treat the numbers as a relative measure across changes rather than an absolute throughput figure.
To profile a hot path:
go test -bench=BenchmarkCreateUser -cpuprofile cpu.out ./...
go tool pprof -http=:8081 cpu.out
make bench-cpu and make bench-mem wrap the same thing for the framework's own suite.
Configure the server for production
app.Listen(addr) calls http.ListenAndServe with no timeouts, which leaves you exposed to slow clients. In production, build the server yourself:
srv := &http.Server{
Addr: ":8080",
Handler: app,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
Long-lived streaming routes need a WriteTimeout of zero, or their own server, since the timeout applies to the whole response.
Next steps
- Middleware for what runs on every request.
- Roles and permissions for caching authorization lookups.