JSON can be the expensive part

Framework overhead is easy to benchmark, while response shape quietly changes the request.

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:

  • /small returns one JSON object.
  • /list returns 1,000 JSON objects.
  • /parse accepts 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.

TRIP COMPUTER / SESSION

TRIP A

Current drive.

A private counter for this browser session. Nothing here is transmitted or retained after the session ends.

Elapsed
00:00
Sections
0
Notes
0
Screens
0

Route/

Build platebe587d2
Chassis
v7.1.3
Revision
be587d2
Last serviced
09 Sept 2026

OWNER’S MANUAL / WTHRAJAT

OPERATING NOTES

How this thing moves.

The header behaves like a small mechanical system. Its readings respond to how you move through the site.

Throttle
Scrolling is input. Faster downward movement builds more momentum and engine speed.
Transmission
Upshifts follow sustained input. Scrolling upward slows and downshifts, and may briefly show reverse.
Idle
When input stops, RPM settles near 850 with mechanical drift. The gearbox eventually returns to neutral.
Tachometer
The needle always follows the reported RPM. It is never calculated from your position on the page.
Trip A
Session time, explored sections, opened notes and approximate screens travelled stay in this tab session.

Controls

Ctrl K
Search notes
?
Open this manual
Esc
Close an instrument
Tab
Move through controls