After I published the Go framework benchmark, the part I kept thinking about was
all the work I had removed to make the comparison clean. Every server matched one
route and returned pong, which is a decent way to isolate routing overhead and a
pretty strange impression of an API. My actual handlers parse input, validate it,
wait for a database, build a response, encode that response, maybe gzip it, and
then send far more than four bytes over the network. The router gets a lot less
interesting once the request has a job.
The benchmark remains useful for the narrow question I gave it, and I enjoy
knowing how much overhead each framework adds to an almost empty handler. Trouble
begins when I turn /ping into a prediction about a real service. A framework can
win the router race and then spend most of the actual request doing exactly the
same JSON work as everybody else.
Give the handler some work#
This example is still fake, although it is closer to the kind of work hiding in a normal list endpoint:
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Roles []string `json:"roles"`
CreatedAt string `json:"created_at"`
}
func handler(w http.ResponseWriter, r *http.Request) {
users := make([]User, 0, 1000)
for i := 0; i < 1000; i++ {
users = append(users, User{
ID: i,
Name: "Rajat",
Email: "rajat@example.com",
Roles: []string{"admin", "writer"},
CreatedAt: "2026-02-14T01:10:00+05:30",
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
The router finds /users and is basically done. The app still has to create a
thousand structs, walk every field, escape strings, repeat the keys, write the
bytes, and clean up enough allocations to get the garbage collector interested.
A few microseconds saved during route matching can disappear very quickly inside
that work.
JSON encourages me to forget this because the expensive-looking operation is one pleasant line:
json.NewEncoder(w).Encode(users)
The runtime is doing real work behind that line. Small responses rarely make this interesting, but a large list with nested objects and repeated field names can show up as CPU time, memory pressure, a slower p99, or gzip becoming visible in a profile. The symptom usually looks like general slowness rather than a friendly message saying “hello, I am JSON”. Checking the database first is sensible. Every so often the database is innocent and the app is spending a surprising amount of time turning objects into text.
Measure the boring work too#
I would keep /ping if I ran the benchmark again, then add routes with a little
more responsibility:
/smallreturns one JSON object./listreturns 1,000 JSON objects./parseaccepts JSON and returns JSON.
My guess is that /ping will keep showing router overhead, /small may preserve
some runtime differences, and /list will become mostly an encoding and memory
test. I could be wrong, which is exactly why I want another benchmark instead of
a confident sentence assembled from vibes.
The wire format itself matters too. This is lovely to read:
[
{ "id": 1, "name": "A", "role": "admin" },
{ "id": 2, "name": "B", "role": "admin" }
]
It also sends the keys again for every row. Nobody cares at two rows. At twenty thousand rows the response contains a lot of repeated text, and gzip fixes some of that by spending more CPU. Engineering has a charming habit of moving cost from one pocket to another and announcing a saving.
I still like JSON and have no urge to invent a binary protocol for an ordinary API so I can feel wise. It is familiar, debuggable, and good enough for most of what I build. I just want response size and encoding time in the investigation before I swap frameworks or blame a router. Even a plain log line gives me a useful starting point:
route=/users status=200 bytes=482931 duration_ms=88
For a short investigation I might time the encoding directly:
started := time.Now()
payload, err := json.Marshal(users)
encodeDuration := time.Since(started)
I would remove the timing once I learned enough. When encoding does show up, the fix is usually deeply unglamorous: return fewer fields, paginate the list, stop repeating a huge nested object, or stream the response when memory is the actual problem. Framework benchmarks are still fun, and I will absolutely keep running them. I just need to remember that the router finishes its lap before most real endpoints have tied their shoes.