Skip to content
gohacks.dev
APIs

Benchmark a Go JSON API Handler From Request to Response

Benchmark a Go JSON handler end to end: Encoder versus Marshal versus a pooled buffer versus cached bytes, plus what strict decoding costs, with allocations.

10 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the JSON logo.

Optimising an API handler without measuring is guesswork, and measuring only the encoding step misses the point of a handler: it reads a request, does something, and writes a response. This article benchmarks that whole path for a typical JSON API, compares the common ways of writing the response, and checks the cost of decoding strictly. The goal is not a list of winners. It is a feel for where the time goes, so that the next time someone says "we should pool the buffers", you know what that is worth.

Tested with Go 1.25.6, Apple M1 Max, macOS (arm64)

Everything ran on Go 1.25.6 on an Apple M1 Max. Absolute numbers will differ on your hardware; ratios usually survive. For benchmark mechanics in general, benchmarking in Go covers b.N, -benchmem and noise, and profiling with pprof is the next step when a benchmark shows a handler worth tuning. The JSON side is explained in encoding and decoding JSON.

What is being measured

Two handlers sit under test. The list handler returns 50 items, each with an id, a name, a price and three tags, about 5 KB of JSON. The create handler decodes a small JSON body, validates it and returns the created item with a 201.

The list handler comes in four versions:

  • Encoder: json.NewEncoder(w).Encode(items) writes straight into the ResponseWriter.
  • Marshal: json.Marshal, then set Content-Length and Write the bytes. This is the version that can report an encoding error before sending anything.
  • Pooled: encode into a bytes.Buffer taken from a sync.Pool, then write it with a length.
  • Cached: the bytes were encoded once at start-up, and each request just writes them.
handlers.goGo
package main

import (
	"bytes"
	"encoding/json"
	"net/http"
	"strconv"
	"sync"
)

type Item struct {
	ID    int      `json:"id"`
	Name  string   `json:"name"`
	Price float64  `json:"price"`
	Tags  []string `json:"tags"`
}

var items = func() []Item {
	out := make([]Item, 50)
	for i := range out {
		out[i] = Item{ID: i + 1, Name: "Item number " + strconv.Itoa(i+1), Price: float64(i) + 0.99, Tags: []string{"go", "api", "bench"}}
	}
	return out
}()

// listEncoder streams straight into the ResponseWriter.
func listEncoder(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(items)
}

// listMarshal builds the whole body first, so it can set Content-Length and report an encoding error before writing.
func listMarshal(w http.ResponseWriter, r *http.Request) {
	b, err := json.Marshal(items)
	if err != nil {
		http.Error(w, "encode failed", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Content-Length", strconv.Itoa(len(b)))
	w.Write(b)
}

var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

// listPooled marshals into a reused buffer.
func listPooled(w http.ResponseWriter, r *http.Request) {
	buf := bufPool.Get().(*bytes.Buffer)
	defer bufPool.Put(buf)
	buf.Reset()
	if err := json.NewEncoder(buf).Encode(items); err != nil {
		http.Error(w, "encode failed", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
	w.Write(buf.Bytes())
}

// listCached serves bytes that were encoded once, which is valid only while the data does not change.
var cachedList, _ = json.Marshal(items)

func listCached(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Content-Length", strconv.Itoa(len(cachedList)))
	w.Write(cachedList)
}

type createReq struct {
	Name  string   `json:"name"`
	Price float64  `json:"price"`
	Tags  []string `json:"tags"`
}

// create decodes, validates and echoes the new item: the full request-to-response path.
func create(strict bool) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var in createReq
		dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16))
		if strict {
			dec.DisallowUnknownFields()
		}
		if err := dec.Decode(&in); err != nil || in.Name == "" || in.Price < 0 {
			http.Error(w, `{"error":"invalid"}`, http.StatusUnprocessableEntity)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusCreated)
		json.NewEncoder(w).Encode(Item{ID: 51, Name: in.Name, Price: in.Price, Tags: in.Tags})
	}
}

func main() {}

The benchmarks use a ResponseWriter that discards its input, not httptest.NewRecorder. The recorder keeps everything in a growing buffer, which adds allocation and copying that has nothing to do with the handler. A test confirms that all four list variants produce identical bytes, because a faster handler that returns different output is a bug.

handlers_test.goGo
package main

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

// discard is a ResponseWriter that keeps nothing, so the benchmark measures the handler and not the recorder's buffer growth.
type discard struct{ h http.Header }

func (d *discard) Header() http.Header         { return d.h }
func (d *discard) Write(p []byte) (int, error) { return len(p), nil }
func (d *discard) WriteHeader(int)             {}

func benchList(b *testing.B, h http.HandlerFunc) {
	req := httptest.NewRequest("GET", "/items", nil)
	w := &discard{h: http.Header{}}
	b.ReportAllocs()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		clear(w.h)
		h(w, req)
	}
}

func BenchmarkListEncoder(b *testing.B) { benchList(b, listEncoder) }
func BenchmarkListMarshal(b *testing.B) { benchList(b, listMarshal) }
func BenchmarkListPooled(b *testing.B)  { benchList(b, listPooled) }
func BenchmarkListCached(b *testing.B)  { benchList(b, listCached) }

const body = `{"name":"Widget","price":9.99,"tags":["go","api"]}`

func benchCreate(b *testing.B, h http.HandlerFunc) {
	w := &discard{h: http.Header{}}
	b.ReportAllocs()
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		req := httptest.NewRequest("POST", "/items", strings.NewReader(body))
		clear(w.h)
		h(w, req)
	}
}

func BenchmarkCreateLenient(b *testing.B) { benchCreate(b, create(false)) }
func BenchmarkCreateStrict(b *testing.B)  { benchCreate(b, create(true)) }

// A baseline for the request construction included in the create benchmarks above.
func BenchmarkNewRequestOnly(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		httptest.NewRequest("POST", "/items", strings.NewReader(body))
	}
}

func TestSameBytes(t *testing.T) {
	var out []string
	for _, h := range []http.HandlerFunc{listEncoder, listMarshal, listPooled, listCached} {
		rec := httptest.NewRecorder()
		h(rec, httptest.NewRequest("GET", "/items", nil))
		out = append(out, strings.TrimSpace(rec.Body.String()))
	}
	for i := 1; i < len(out); i++ {
		if out[i] != out[0] {
			t.Fatalf("variant %d produces different JSON", i)
		}
	}
}
Shell
go test -run TestSameBytes -count=1 -v . | grep -E '^(--- |ok)' | sed -E 's/ \([0-9.]+s\)//'
go test -run='^$' -bench=. -benchmem -count=3 . | grep -E '^(goos|goarch|cpu|Benchmark)'
Text
--- PASS: TestSameBytes
ok  	example.test/lab	0.379s
goos: darwin
goarch: arm64
cpu: Apple M1 Max
BenchmarkListEncoder-10       	  119202	      9313 ns/op	      40 B/op	       2 allocs/op
BenchmarkListEncoder-10       	  128493	      9324 ns/op	      40 B/op	       2 allocs/op
BenchmarkListEncoder-10       	  129758	      9387 ns/op	      40 B/op	       2 allocs/op
BenchmarkListMarshal-10       	  120405	      9987 ns/op	    4159 B/op	       5 allocs/op
BenchmarkListMarshal-10       	  120474	      9938 ns/op	    4159 B/op	       5 allocs/op
BenchmarkListMarshal-10       	  115576	      9957 ns/op	    4158 B/op	       5 allocs/op
BenchmarkListPooled-10        	  125961	      9834 ns/op	      60 B/op	       4 allocs/op
BenchmarkListPooled-10        	  124321	      9475 ns/op	      60 B/op	       4 allocs/op
BenchmarkListPooled-10        	  126823	      9509 ns/op	      60 B/op	       4 allocs/op
BenchmarkListCached-10        	10194675	       115.7 ns/op	      36 B/op	       3 allocs/op
BenchmarkListCached-10        	 9686395	       126.9 ns/op	      36 B/op	       3 allocs/op
BenchmarkListCached-10        	 9756982	       120.5 ns/op	      36 B/op	       3 allocs/op
BenchmarkCreateLenient-10     	  474274	      2520 ns/op	    6453 B/op	      29 allocs/op
BenchmarkCreateLenient-10     	  475590	      2540 ns/op	    6453 B/op	      29 allocs/op
BenchmarkCreateLenient-10     	  492890	      2582 ns/op	    6453 B/op	      29 allocs/op
BenchmarkCreateStrict-10      	  462759	      2693 ns/op	    6453 B/op	      29 allocs/op
BenchmarkCreateStrict-10      	  440010	      2633 ns/op	    6453 B/op	      29 allocs/op
BenchmarkCreateStrict-10      	  462936	      2527 ns/op	    6453 B/op	      29 allocs/op
BenchmarkNewRequestOnly-10    	 1000000	      1151 ns/op	    5154 B/op	      11 allocs/op
BenchmarkNewRequestOnly-10    	 1000000	      1086 ns/op	    5154 B/op	      11 allocs/op
BenchmarkNewRequestOnly-10    	 1000000	      1068 ns/op	    5154 B/op	      11 allocs/op

The list endpoint

VariantTime per requestBytes allocatedAllocations
Encoderabout 9.3 us402
Marshal then Writeabout 9.95 us4,1595
Pooled bufferabout 9.5 us604
Cached bytesabout 0.12 us363

Three runs of each (-count=3) agreed within a few percent; the cached case varied between 116 and 127 ns.

The three live encoding strategies are close. Marshal is about 7 percent slower than the encoder, mostly because it allocates a 4 KB slice and copies the output once more into the writer. Pooling the buffer recovers nearly all of that, at the cost of more code and a rule to follow: the buffer must not be used after it goes back to the pool. If you use Write after Put, you have a data race that no benchmark will show.

The Encoder version allocates almost nothing because encoding/json keeps its own internal pool of encoding state, so there is little left for you to save. The cost of the whole operation is the encoding itself, reflecting over fifty structs, formatting numbers and escaping strings. About 9 microseconds for 5 KB is roughly 550 MB/s, which is respectable for the standard library.

Choose by behaviour, not speed. The reason to pick Marshal is correctness: it detects an encoding failure before any byte reaches the client, so you can return a clean 500. The Encoder version has already sent a 200 and part of the body by the time an error occurs, and all you can do is cut the connection. It also gives you a Content-Length header, which lets clients show progress and keep-alive connections reuse cleanly. With a 7 percent difference at 9 us, the choice is easy: Marshal (or the pooled variant) unless the response is large enough that holding it all in memory matters, in which case stream it, as in streaming large JSON responses.

Caching is in a different league. Serving pre-encoded bytes took 0.12 us, about 80 times faster than encoding. That is the entire difference between "free" and "9 microseconds per request", and it is why the biggest optimisation for a read-heavy endpoint is almost always to avoid encoding at all. Cache the bytes keyed by whatever changes the response and invalidate them when the data changes, as discussed in caching API responses with Redis and ETags. The danger is staleness, not speed: the benchmark cannot tell you whether the cache is correct.

The create endpoint

The create benchmark builds a fresh httptest.NewRequest each iteration, because a request body can only be read once. Building the request itself costs about 1.1 us and 11 allocations, which the last benchmark isolates. The handler benchmarks include that cost.

VariantTimeAllocations
Decode with unknown fields allowedabout 2.5 us29
Decode with DisallowUnknownFieldsabout 2.7 us29
Building the request onlyabout 1.1 us11

Subtract the request construction and the handler itself, decode, validate and encode a small response, takes about 1.4 us. Strict decoding adds roughly 0.1 us, within what I would call a rounding error compared with a database call that takes a thousand times longer. If you want unknown fields rejected for API hygiene (and you probably do), do not let a benchmark talk you out of it.

What these numbers say about real servers

Compare the 9 microsecond encoding with what surrounds it in production. A database query takes hundreds of microseconds to milliseconds. A network round trip within a data centre takes about half a millisecond. TLS adds more. In a realistic request, JSON encoding of a 5 KB response is a percent or two of the latency. It becomes a bottleneck only at high request rates on CPU-bound services, or with large payloads, where it can account for most of the CPU: at 20,000 requests per second, 9 microseconds each is 18 percent of one core.

So the benchmarks matter in two situations. The first is a hot endpoint serving large responses to many clients, where the CPU spent on encoding is real money. The second is when you are deciding between two designs and want to know whether the difference is worth arguing about. Here it is not: Encoder, Marshal and pooled are within noise of each other in any real deployment.

Benchmarking pitfalls this one avoids

The recorder trap. httptest.NewRecorder retains the whole body and grows. Benchmarking against it measures buffer growth. Use a discarding writer or reuse a recorder and reset it.

Hidden setup in the loop. Request construction is inside the loop in the create benchmark because it has to be. The baseline benchmark shows how much of the figure is setup, so you can subtract it instead of pretending it is not there.

Different outputs. The test that all variants return identical bytes is part of the benchmark file for a reason. If one variant is faster because it returns less, you have a bug and a misleading number.

The compiler removing work. If a result is never used, the compiler can eliminate the work. Here the handlers write into a writer, which is a side effect it cannot drop. For pure functions, assign results to a package level variable.

Noise. Run with -count=3 or more and look at the spread. For comparing two versions properly, use benchstat, which runs a statistical test across repeated runs; I did not use it here, since the differences are large or well inside the noise.

Treating a microbenchmark as a load test. A benchmark runs one goroutine in a loop with a warm cache. It says nothing about contention, GC under memory pressure, or what happens with 10,000 connections. For that, see load testing a Go API.

What to do with the result

If an endpoint is hot, measure it first with a realistic payload. Start with the biggest lever: avoid work (cache, smaller payloads, fewer fields, pagination). Then look at allocations in the profile. Only if encoding still dominates consider a faster encoder: encoding/json/v2 is available behind a flag in recent Go releases (JSON v2, and the JSON v2 blog post), and several third-party encoders generate code for your types. Each comes with its own correctness trade-offs, so re-run the identical-bytes test when you switch. The testing package documentation at pkg.go.dev describes how b.N and -benchmem work.

Taggedbenchmark
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.