<!--
  Performance Optimization
  From the Iris book, chapter 20, covering Iris 14.0.0 on Go 1.27.
  Import path: github.com/kataras/iris/v14
  Canonical page: https://iris-go.com/docs/performance-optimization/
-->
# Chapter 20: Performance Optimization

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

```sh
# 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)
        }
    }
}
```

```sh
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](https://pkg.go.dev/golang.org/x/perf/cmd/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](#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:

\[Diagram: 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](https://iris-go.com/docs/iris-newbuilder/)).

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](https://iris-go.com/docs/response-handling/)) 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](https://iris-go.com/docs/response-handling/).

## 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](https://iris-go.com/docs/reading-requests/).

### 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](https://iris-go.com/docs/reading-requests/), the response shape in
[Chapter 6](https://iris-go.com/docs/response-handling/).

## 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](https://iris-go.com/docs/caching-strategies/); 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
`SELECT`s 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](https://iris-go.com/docs/database-integration/).

## 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](https://iris-go.com/docs/iris-newbuilder/)).
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](https://iris-go.com/docs/caching-strategies/).
- Background goroutines take `ctx.WithoutCancel()`, never the pooled
  `iris.Context`; bounded worker pools convert overload into fast
  `503`s instead of unbounded latency.
- Touch `GOGC`/`GOMEMLIMIT` only with a measurement on the screen.

## Further Reading

- [Profiling Go Programs](https://go.dev/blog/pprof): the classic
  walkthrough of finding and fixing a real hotspot with pprof.
- [net/http/pprof](https://pkg.go.dev/net/http/pprof): the standard
  library package behind the profiling endpoints and the full list of
  available profiles.
- [A Guide to the Go Garbage Collector](https://go.dev/doc/gc-guide):
  how the GC works, what allocations cost, and what `GOGC` and
  `GOMEMLIMIT` actually change.
- [Go documentation: Managing connections](https://go.dev/doc/database/manage-connections):
  the official guidance behind the `database/sql` pool settings used
  in this chapter.
- [testing package: Benchmarks](https://pkg.go.dev/testing#hdr-Benchmarks):
  the benchmark harness that `go test -bench` runs, including
  `b.N`, `ReportAllocs` and timer control.
