Chapter 20

Performance Optimization

Measure before changing anything. Response size, request bounds, caching, database access patterns, concurrency in handlers and runtime tuning.

Performance work starts with a measurement, not a hunch. Most web services are slowed down by a handful of causes: oversized responses, unbounded request bodies, chatty database access, accidental serialization of work. Every one of them shows up clearly in a profile or a benchmark before it shows up in prose. This chapter shows how to measure an Iris application, what the framework already optimizes so you do not re-optimize it, and where deliberate choices (compression, streaming, pagination, caching, concurrency) buy real wins.

Background

Two numbers describe a service’s speed, and they are not the same number. Latency is how long one request takes from arrival to last byte: what an individual user feels. Throughput is how many requests the service completes per second: what capacity planning cares about. They often pull in opposite directions: batching work raises throughput but makes each request wait, and shedding load protects latency by lowering throughput. Decide which one you are optimizing before you change anything, because “faster” without a noun is not a goal.

Intuition about where a program spends its time is wrong more often than it is right. The code that looks slow and the code that is slow are usually different code. A profiler settles the argument: it samples what the running program is doing many times per second and produces a map of where CPU time and memory actually go, function by function and call path by call path. With that map you fix the few places that dominate the cost; without it you polish code that was never hot. That is why every section of this chapter begins, one way or another, with a measurement.

One Go-specific concept pays for itself here. When your handler creates values that outlive its stack frame, the runtime allocates them on the heap, and the garbage collector (GC) later spends CPU finding and reclaiming the ones no longer in use. The more a server allocates per request, the more often the GC runs and the more processor time it borrows from request handling, which is why Go benchmarks report allocations per operation next to time per operation, and why the framework pools and reuses its per-request machinery instead of reallocating it.

The last background idea is the danger of optimizing the wrong thing. Speeding up a step that accounts for two percent of request time can never win more than two percent, no matter how brilliant the code, and every optimization costs complexity that you keep paying after the speed is forgotten. A change that does not move a measured number is not an optimization; it is a rewrite with extra risk. The chapter’s ordering (measure, then learn what Iris already does, then reach for the levers) exists to keep your effort pointed at costs that are real.

Measure First

Three tools cover almost every performance question: pprof answers “where does CPU and memory go”, go test -bench answers “did my change help”, and the httpcost middleware answers “what does one request cost in production”.

Profiling with pprof

Go ships its profiler in the standard library under the name pprof. A CPU profile samples the call stacks that are executing, many times per second over a window: functions that appear in many samples are where processor time goes. A heap profile records which call sites allocated the memory that is still live at the moment of the snapshot. The go tool pprof command reads both and renders tables and call graphs from them.

The middleware/pprof package mounts Go’s standard profiling endpoints under a route group. Profiles expose internals of your process. Never leave them open to the public. Mount them behind authentication (or bind them to an internal-only listener):

Go
package main

import (
    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/middleware/basicauth"
    "github.com/kataras/iris/v14/middleware/pprof"
)

func main() {
    app := iris.New()

    auth := basicauth.Default(map[string]string{
        "ops": "a-strong-password",
    })
    app.Router("/debug/pprof", auth, pprof.New())

    app.Get("/", func(ctx iris.Context) {
        ctx.JSON(iris.Map{"message": "hello"})
    })

    app.Listen(":8080")
}

pprof.New() returns a routing controller; app.Router registers the auth handler as middleware for the group and mounts the controller under it. Visiting /debug/pprof lists the available profiles; the go tool pprof command consumes them directly (credentials go in the URL):

Shell
# 30-second CPU profile, opened interactively.
go tool pprof "http://ops:a-strong-password@localhost:8080/debug/pprof/profile?seconds=30"

# Heap snapshot: where does live memory come from?
go tool pprof "http://ops:a-strong-password@localhost:8080/debug/pprof/heap"

Inside the tool, top shows the heaviest functions and web renders a call graph. Profile under realistic load. An idle server profiles as nothing but scheduler noise.

Benchmarking handlers

For “did this change help”, write a Go benchmark against the built application. The application itself serves http.Handler after Build(), so the standard library’s recorder drives it without any network in the way, the same technique the framework’s own benchmark suites use:

Go
package main

import (
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/kataras/iris/v14"
)

func newBenchApp(b *testing.B) *iris.Application {
    app := iris.New()
    app.Get("/books/{id:int}", func(ctx iris.Context) {
        id := ctx.Params().GetInt("id")
        ctx.JSON(iris.Map{"id": id, "title": "A Book"})
    })

    if err := app.Build(); err != nil {
        b.Fatal(err)
    }

    return app
}

func BenchmarkGetBook(b *testing.B) {
    app := newBenchApp(b)
    req := httptest.NewRequest("GET", "/books/42", nil)

    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        w := httptest.NewRecorder()
        app.ServeHTTP(w, req)
        if w.Code != http.StatusOK {
            b.Fatalf("unexpected status %d", w.Code)
        }
    }
}
Shell
go test -bench=BenchmarkGetBook -benchmem -count=6

-benchmem reports bytes and allocations per operation alongside time per operation. Reusing one request value is fine for GET; for POST benchmarks rebuild the body reader each iteration, because a body is consumed on first read.

Read benchmark output as deltas, not absolutes: numbers depend on the machine, its load, and the Go version, so the only meaningful comparison is before-versus-after on the same box. Run with -count=6 or more and compare the two sets with benchstat, which reports whether a difference is statistically significant. A change that does not move ns/op, B/op, or allocs/op is not an optimization, whatever the code looks like.

Per-request cost with httpcost

Benchmarks run in a lab; the middleware/httpcost package measures requests in the running application. It samples time, CPU, and memory around the handler chain and folds them into a single configurable cost figure:

Go
package main

import (
    "github.com/kataras/iris/v14"
    "github.com/kataras/iris/v14/middleware/httpcost"
)

func main() {
    app := iris.New()

    // Outer middleware reads the metric after the chain completes.
    app.Use(func(ctx iris.Context) {
        ctx.Next() // run httpcost and the handlers first.

        if metric, ok := httpcost.Get(ctx); ok {
            ctx.Application().Logger().Infof(
                "%s %s: duration=%s cpu=%.4fs net=%dB cost=%.6f",
                ctx.Method(), ctx.Path(),
                metric.Duration, metric.CpuTime,
                metric.NetworkUsed, metric.Cost)
        }
    })
    app.Use(httpcost.New(httpcost.DefaultOptions))

    app.Get("/heavy", func(ctx iris.Context) {
        // ... some expensive work ...
        ctx.JSON(iris.Map{"done": true})
    })

    app.Listen(":8080")
}

The Metric carries Duration, CpuTime (seconds), MemoryUsed and NetworkUsed (bytes), and the weighted Cost. The weights come from httpcost.Options (CostPerSecond, CostPerCPU, CostPerMB), and any dimension can be disabled by setting it to zero. There is also httpcost.Compute(job func()) float64 for measuring an arbitrary function outside a request.

Be honest about what the numbers mean: CPU and memory are process-wide deltas taken before and after the request, so under concurrent load a request is charged a share of its neighbors’ work. The figures are excellent for spotting outliers and trends, not for exact accounting.

What Iris Already Does

Knowing what the framework optimizes tells you where not to spend effort.

Contexts are pooled. For every request, Iris takes a *Context and a response writer from sync.Pool-backed pools, serves the request, and returns both. The stores behind ctx.Params() and ctx.Values() are slices, not maps, and are reset (keeping their capacity) when the context is released, so steady-state traffic reuses the same storage instead of reallocating it. The corollary is a hard rule: never retain an iris.Context past the handler’s return; it will be reused by another request (see Concurrency in Handlers for the safe pattern).

Routes are compiled once. Build() turns the registered routes into prefix trees, one per HTTP method and subdomain. Matching a request is a segment-by-segment walk of that tree: static segments are looked up, dynamic ones capture parameters, and the built-in typed parameters ({id:int}, {id:uuid}, …) are validated by plain Go evaluator functions selected at registration time. There is no regular expression engine and no route-list scan at request time, so matching cost does not degrade as the route table grows.

Accessors are typed, not reflective. ctx.Params().GetInt("id") and ctx.Query().GetString("q") read from those slice-backed entries and convert with strconv: no reflection, no intermediate maps. Missing values return the documented zero results ("" for strings, -1 for integers) rather than allocating error values on every probe.

JSON is streamed. With default options, ctx.JSON encodes straight into the response writer with a json.Encoder; the payload is never first assembled into an intermediate buffer by the framework.

The practical consequence: hand-rolled context pools, “fast” routing layers in front of Iris, and custom JSON encoder pools re-implement work the framework already does. Profile before adding any of them.

The rest of this chapter is the other half of that picture. Each lever below attaches to a stage the framework has already made cheap, which is why the measurement comes first and the lever second:

What is already optimised, and what you can change The framework already pools the Context, compiles the routes, avoids reflection in the typed accessors and streams JSON, so the levers worth pulling are the ones attached to your own code, and only after you have measured. Measure first, always: pprof.New() behind auth for where CPU and heap actually go, go test -bench -benchmem -count=6 with benchstat for whether a change helped, and httpcost.New for the cost in production. ALREADY OPTIMISED: REBUILDING ANY OF THIS IS WASTED EFFORT Context and response writer from sync.Pool Params() and Values() are slices, reset with their capacity kept route match: one prefix tree per method and subdomain, built in Build() typed parameters are evaluated by plain Go functions, with no regexp engine typed accessors: ctx.Params().GetInt, ctx.Query().GetString strconv, with no reflection and no intermediate maps ctx.JSON: a json.Encoder straight into the response writer with the default options the payload is never buffered first YOUR LEVERS, EACH ATTACHED TO THE STAGE ABOVE IT BodyMaxMemory, or bodylimit.New(n) per route 413 before a byte of the body is read ctx.Paginated, and ctx.ReadJSONStream for large arrays one page in the query and one page in the response; memory stays proportional to one element keep the default iris.JSON options on hot paths ASCII, Secure or Prefix switch to a buffered path. Indent does not. compress.Handler on compressible groups only, and ctx.StreamWriter wasted CPU on JPEG, PNG and ZIP; a flush per callback, so batch tens of kilobytes NOT ON THE REQUEST PATH AT ALL Caching is the request you never process at all (Chapter 14). ctx.WithoutCancel() for work that must outlive the response, and never the pooled Context itself. A bounded worker pool with select/default: a 503 now beats unbounded latency later. GOGC and GOMEMLIMIT, one knob at a time, under load, with a number on the screen.
The framework already pools the Context, compiles the routes, avoids reflection in the typed accessors and streams JSON, so the levers worth pulling are the ones attached to your own code, and only after you have measured.

Response Efficiency

Bytes on the wire are usually the largest share of user-visible latency. Three levers control them.

Compression

The middleware/compress package negotiates the response encoding from the client’s Accept-Encoding header (gzip, deflate, brotli, snappy, s2 and zstd are built in) and transparently decompresses request bodies that arrive with a Content-Encoding:

Go
import "github.com/kataras/iris/v14/middleware/compress"

app.Use(compress.Handler)

Applications assembled with iris.NewBuilder() enable the same middleware with the Compression(true) step (Chapter 3).

Compression is a trade: CPU per request in exchange for fewer bytes. It pays off on text (JSON, HTML, CSV) and is wasted on content that is already compressed (JPEG, PNG, ZIP, most video), where it burns CPU to save nothing. If your application serves both kinds, register the middleware on the route groups that produce compressible content instead of the root. And as always: measure. On a fast internal network, compressing small payloads can cost more latency than it saves; behind slow client links it is one of the cheapest wins available.

JSON encoding options

ctx.JSON(v) accepts an optional iris.JSON options value with Indent, Prefix, EscapeHTML, ASCII, Secure and MarshalOptions fields. The default (all zero) is also the fastest configuration: compact output from the standard library’s encoding/json/v2 encoder (Go 1.27), streamed directly to the writer, with HTML characters left unescaped because application/json responses gain nothing from the escaping. Indentation inflates the payload and adds encoding work, and setting EscapeNonASCII, ArrayPrefix or Prefix switches to a buffered encoding path. Keep the defaults on hot endpoints; pretty-printing is for humans reading a terminal, and clients that want it can pipe through jq. The ctx.JSONOrError helper (Chapter 6) uses the same encoder underneath and adds error handling, so it inherits the same fast path.

Streaming large responses

Building a multi-megabyte export in memory and writing it in one call holds the whole payload alive and delays the first byte until the last row is ready. Stream instead: write a chunk, flush, repeat. ctx.StreamWriter runs that loop for you: it calls your function, flushes what was written, and calls it again until you return a non-nil error (io.EOF is the conventional “done”) or the client disconnects, in which case it returns the request context’s cancellation error:

Go
import (
    "fmt"
    "io"

    "github.com/kataras/iris/v14"
)

func exportCSV(ctx iris.Context) {
    ctx.SetContentType("text/csv")

    next := rowFetcher() // yields one batch of rows per call.
    ctx.StreamWriter(func(w io.Writer) error {
        rows, ok := next()
        if !ok {
            return io.EOF // any non-nil error ends the stream.
        }

        for _, row := range rows {
            if _, err := fmt.Fprintln(w, row); err != nil {
                return err
            }
        }

        return nil // flush this chunk, call again.
    })
}

Each callback return is a flush, and flushing has its own cost. Write batches (hundreds of rows, tens of kilobytes) per call rather than one row at a time. For hand-rolled loops, ctx.Write plus ctx.ResponseWriter().Flush() gives the same behavior with manual control; check ctx.IsCanceled() between chunks so a disconnected client does not keep the loop running. Both patterns, including server-sent events, are covered in Chapter 6.

Request Efficiency

The mirror image of response streaming: do not buffer what you do not need, and do not accept what you cannot afford.

Cap request bodies

An unbounded body is a memory-exhaustion vector before it is a performance problem. ContextOptions.BodyMaxMemory caps the bytes any body-reading method (Body, ReadJSON, ReadJSONStream, ReadMultipartRelated, and everything built on them) will consume; past the limit they fail with context.ErrRequestEntityTooLarge, which the rest/errors map renders as 413 TOO_LARGE with the message “request body too large”:

Go
app := iris.New(iris.Options{
    Context: iris.ContextOptions{
        BodyMaxMemory: 4 * iris.MB,
    },
})

For per-route limits (a generous cap on an upload endpoint, a tight one everywhere else), use the bodylimit middleware: app.Use(bodylimit.New(2 * iris.MB)) (github.com/kataras/iris/v14/middleware/bodylimit). Details in Chapter 5.

Stream large arrays

When a client legitimately sends a large JSON array, decoding it into one slice holds every element in memory at once. ctx.ReadJSONStream decodes one object at a time, so memory stays proportional to a single element; process each item (or batch) as it arrives:

Go
batch := make([]Item, 0, 500)
err := ctx.ReadJSONStream(func(decode iris.DecodeFunc) error {
    var item Item
    if err := decode(&item); err != nil {
        return err
    }

    batch = append(batch, item)
    if len(batch) == cap(batch) {
        if err := svc.InsertBatch(ctx, batch); err != nil {
            return err
        }
        batch = batch[:0]
    }

    return nil
})

Paginate instead of loading everything

The most common self-inflicted performance wound is SELECT * into a slice and ctx.JSON of the whole table. ctx.Paginated reads ?page=&size= (plus an optional JSON filter body), runs your collector with LIMIT/OFFSET-ready options, and responds with a page envelope:

Go
import (
    "context"

    "github.com/kataras/iris/v14"
)

func listBooks(ctx iris.Context) {
    ctx.Paginated(func(reqCtx context.Context,
        opts iris.PageOptions, filter BookFilter) ([]Book, int64, error) {
        return svc.Books(reqCtx, opts, filter)
    })
}

The database returns one page, the handler allocates one page, the client downloads one page. Input handling is detailed in Chapter 5, the response shape in Chapter 6.

Caching

The cheapest request is the one you never process. Iris supports the full ladder: Cache-Control/ETag headers and conditional 304 Not Modified responses through the cache middleware, server-side response caching for expensive endpoints, and application-level caches in your service layer. Which rung to use, and the harder question of how to invalidate it, is the subject of Chapter 14; treat it as the second step of performance work, right after measuring, because no handler optimization competes with not running the handler.

Database Efficiency

In a typical CRUD service the database dominates the latency profile, and the fixes are database-side disciplines rather than Iris APIs.

Opening a database connection is expensive (a TCP handshake, authentication, session setup), so clients keep a connection pool: a set of already-open connections that requests borrow and return. That makes the pool’s size a real tuning decision rather than an implementation detail.

Size the connection pool deliberately. database/sql pools connections for you, but its defaults are not a decision. Cap open connections at what your database can actually serve concurrently, keep a few idle so bursts do not pay connection-setup latency, and recycle connections so load-balancer and server-side timeouts do not surprise you:

Go
db.SetMaxOpenConns(50)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(time.Hour)
db.SetConnMaxIdleTime(10 * time.Minute)

An oversized pool moves the queue from your application (visible, measurable) into the database (opaque, and it slows every other client too).

Kill N+1 queries. Fetching a page of 50 orders and then issuing 50 SELECTs for their customers turns one round trip into fifty-one. The fix is a join or a single WHERE id IN (...) batch. N+1 patterns hide well in code and show up loudly in per-request measurement: an endpoint whose duration scales with its item count is the classic signature.

Prepare hot statements. Preparing a statement once and executing it many times saves the database re-parsing and re-planning the query. Some drivers cache prepared statements transparently; check yours before building a caching layer around db.Prepare that the driver already provides.

Select what you serve. Query the columns the response needs, let the pagination options above bound the row count, and push filtering into WHERE clauses rather than Go loops.

Integration patterns (including github.com/kataras/pg, whose error mapping plugs into the Iris error registry) are covered in Chapter 13.

Concurrency in Handlers

A goroutine is Go’s lightweight unit of concurrency: a function running independently, scheduled by the Go runtime onto operating system threads. Goroutines are cheap enough that a server happily runs thousands of them at once, which is exactly how Go’s HTTP server works, one per request, with no configuration on your part.

Every request already runs on its own goroutine, so concurrency inside a handler is for one of two things: finishing the response sooner, or moving work out of the request path entirely.

Background work from a handler

The trap is lifetime, twice over. The iris.Context is pooled and reused the moment the handler returns, so a goroutine must never hold it; and the request’s standard context is canceled when the response completes, so a goroutine holding that gets canceled mid-work. ctx.WithoutCancel() solves both: it returns a standard context.Context, detached from request cancellation, with the request’s context values preserved, safe to keep:

Go
app.Post("/orders", func(ctx iris.Context) {
    order, ok := ctx.BindJSON[Order]()
    if !ok {
        return
    }

    if !ctx.OK(svc.Place(ctx, order), nil) {
        return
    }

    // Must-finish work continues after the response is sent.
    go audit.Record(ctx.WithoutCancel(), order)
})

Use ctx.WithoutCancel() for must-finish work and ctx.StdContext(), the request’s own standard context, for work that should die with the request. An outbound HTTP call belongs in the second group: the transport keeps its context for cancel propagation after the handler returns, so it gets ctx.StdContext(), never the pooled ctx itself. Choosing wrongly in either direction produces leaked work or lost work.

Bounded worker pools

Back-pressure is the polite name for saying no early: when a system reaches capacity, it tells the caller to slow down or retry instead of accepting work it cannot finish on time.

Unbounded go statements under load become an unbounded number of concurrent database queries, image encodes, or outbound calls. A fixed-size worker pool (plain Go, no framework involvement) turns that into a queue with back-pressure:

Go
type Job struct {
    OrderID string
}

func startWorkers(n int, process func(Job)) chan<- Job {
    jobs := make(chan Job, 64)
    for i := 0; i < n; i++ {
        go func() {
            for job := range jobs {
                process(job)
            }
        }()
    }

    return jobs
}
Go
import "github.com/kataras/iris/v14/rest/errors"

jobs := startWorkers(4, processOrder)

app.Post("/orders/{id:uuid}/invoice", func(ctx iris.Context) {
    job := Job{OrderID: ctx.Params().GetString("id")}

    select {
    case jobs <- job:
        ctx.StatusCode(iris.StatusAccepted)
    default: // queue full: shed load instead of piling it up.
        errors.Unavailable.Message(ctx, "the invoice queue is full")
    }
})

The default branch is the point: when the queue is full, the handler says so immediately instead of letting latency grow without bound.

Nothing here closes jobs, so an in-flight invoice dies with the process. When that matters, make the pool an iris.Worker: Run owns the goroutines and returns when the shutdown context is done, Stop closes the channel, and Services(pool) ties both to the server’s lifecycle (Chapter 3). Size the pool against the bottleneck resource (database connections, CPU cores, third-party rate limits), and measure, because the right number is an experiment, not a guess.

Runtime Tuning

Go’s runtime needs little help. GOMAXPROCS defaults to the available CPU count and recent Go releases respect container CPU limits on Linux, so overriding it is rarely justified. Two environment knobs are worth knowing: GOGC scales how much the heap grows between garbage collections (higher means fewer, longer-spaced collections at the cost of more memory), and GOMEMLIMIT sets a soft memory ceiling that makes the collector work harder as the process approaches it, the practical way to keep a containerized service inside its memory allocation instead of meeting the OOM killer. Change one knob at a time, under realistic load, with the pprof and metrics tooling from the start of this chapter open. A GC setting you cannot connect to a measured improvement is a superstition, not a configuration.

Summary

  • Measure before optimizing: pprof.New() mounted behind authentication for CPU and heap profiles, go test -bench -benchmem with the recorder-driven skeleton for before/after comparisons, and httpcost.New for per-request cost in production. Compare deltas on the same machine; distrust absolute numbers.
  • Iris already pools contexts and response writers, compiles routes into per-method prefix trees at build time, reads parameters through typed non-reflective accessors, and streams JSON. Do not rebuild any of it.
  • Spend response bytes deliberately: compress.Handler for compressible content, default ctx.JSON options on hot paths, and ctx.StreamWriter for large or slow payloads.
  • Bound requests: BodyMaxMemory (413 past the cap), ctx.ReadJSONStream for large arrays, ctx.Paginated instead of loading whole tables.
  • Database wins are disciplines, not APIs: sized pools, no N+1, prepared hot statements, page-bounded queries. Caching (the biggest win of all) is Chapter 14.
  • Background goroutines take ctx.WithoutCancel(), never the pooled iris.Context; bounded worker pools convert overload into fast 503s instead of unbounded latency.
  • Touch GOGC/GOMEMLIMIT only with a measurement on the screen.

Further Reading

Twenty-five chapters, about 85,000 words. Try BindJSON, graceful shutdown or error map.