Guides

Performance

What ezz precomputes and pools, the rules that keeps, and how to benchmark your own service.

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

WorkWhen
Request binder and validator builtezz.H(...) is called
Struct field metadata cachedFirst time a type is bound or validated
Middleware chain composedRoute registration
Route pattern registered on ServeMuxRoute registration
Request and response types recordedRoute 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

AllocationHandling
*ezz.ContextTaken from a sync.Pool, reset, returned when the handler ends
Response writer wrapperPooled the same way
Query valuesParsed on first access, then cached for the request
Request metadataExtracted once at context creation
Request ID bytesPooled buffer, crypto/rand, hex encoded
Validated JWTCached by token string in the HMAC provider
Resolved authorizationComputed at most once per request, optionally cached across requests

Two rules follow from the pooling:

Do not keep an *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.
Register middleware before routes. Each route captures the chain that exists when it is registered, so 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:

Terminal
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:

Terminal
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

Copyright © 2026