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):
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):
# 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:
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)
}
}
}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:
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:
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:
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:
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”:
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:
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:
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:
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:
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:
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
}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 -benchmemwith the recorder-driven skeleton for before/after comparisons, andhttpcost.Newfor 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.Handlerfor compressible content, defaultctx.JSONoptions on hot paths, andctx.StreamWriterfor large or slow payloads. - Bound requests:
BodyMaxMemory(413 past the cap),ctx.ReadJSONStreamfor large arrays,ctx.Paginatedinstead 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 poolediris.Context; bounded worker pools convert overload into fast503s instead of unbounded latency. - Touch
GOGC/GOMEMLIMITonly with a measurement on the screen.
Further Reading
- Profiling Go Programs: the classic walkthrough of finding and fixing a real hotspot with pprof.
- 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:
how the GC works, what allocations cost, and what
GOGCandGOMEMLIMITactually change. - Go documentation: Managing connections:
the official guidance behind the
database/sqlpool settings used in this chapter. - testing package: Benchmarks:
the benchmark harness that
go test -benchruns, includingb.N,ReportAllocsand timer control.