Skip to content
Huseyin Babal
Go back

Go encoding/json v1 vs v2 under load: Go 1.26, Go 1.27 and MarshalWrite

Go 1.27 ships encoding/json/v2 as a stable package, and the old encoding/json is now implemented on top of it. The Go blog reports Unmarshal improvements of up to 10x and Marshal “roughly at parity” with v1. Those are package-level benchmarks. I wanted to see what the change does to an HTTP API that spends most of its time on JSON, on a machine whose CPUs are fully busy.

Scope first, so the numbers don’t travel further than they should: one Go service, 2 dedicated vCPUs, CPU-bound, a list response of 100 small objects. A service with spare CPU, smaller responses, or a database in the request path can behave differently.

Results

Medians of three 30 second runs per build, measured by k6. Every request returned the expected status, 0 failed checks.

Go 1.26, encoding/jsonGo 1.27, encoding/jsonGo 1.27, encoding/json/v2
GET /notes (100 notes), req/s5,9436,5064,510
GET /notes p95 / p9979.4 / 119.3 ms67.5 / 99.0 ms86.6 / 119.3 ms
POST + DELETE, req/s31,44631,22032,373
POST + DELETE p95 / p9915.0 / 23.7 ms15.1 / 24.2 ms14.3 / 23.1 ms

Rebuilding the unchanged v1 code with Go 1.27 gave about 9% more reads per second than Go 1.26. Switching the handlers to the v2 API lost about 30% of the reads compared with that same Go 1.27 build, and won the write test by 3 to 4%.

The three builds

One API, one file that decides which JSON package it uses:

var v1 = codec{
	name: "v1",
	encode: func(w io.Writer, v any) error {
		return jsonv1.NewEncoder(w).Encode(v)
	},
	decode: func(r io.Reader, v any) error {
		return jsonv1.NewDecoder(r).Decode(v)
	},
}
//go:build goexperiment.jsonv2

var v2 = codec{
	name: "v2",
	encode: func(w io.Writer, v any) error {
		return jsonv2.MarshalWrite(w, v)
	},
	decode: func(r io.Reader, v any) error {
		return jsonv2.UnmarshalRead(r, v)
	},
}

JSON=v2 in the environment picks the second codec at startup. The handlers call c.encode(w, ...) and c.decode(r.Body, ...) and nothing else differs. Builds: Go 1.26.10 and Go 1.27.3, GOOS=linux GOARCH=amd64. The v2 build needs go 1.27 in go.mod; with go 1.26 the toolchain refuses MarshalWrite (“requires go1.27 or later”).

The data is a map of notes in memory, so no database sits in the request path:

type Note struct {
	ID        int64     `json:"id"`
	Title     string    `json:"title"`
	Body      string    `json:"body"`
	Tags      []string  `json:"tags"`
	Done      bool      `json:"done"`
	CreatedAt time.Time `json:"created_at"`
}

Each body has a quoted word, café, 東京 and a newline, so the encoder has escaping and UTF-8 to handle. The full list of 100 notes is about 25 KB of JSON.

The setup

SizeRuns
AppDigitalOcean c-2: 2 dedicated vCPUs, ams3the Go binary
Loadc-8: 8 dedicated vCPUs, same regionk6 v2.3.0, Prometheus, Grafana

The two machines talk over the private network. k6 runs 200 virtual users in a closed loop (constant-vus), so each user sends its next request as soon as the last one returns. The server sat at 180% CPU of 200% in every read run and 169% in the write runs, so the app’s CPUs were the limit and not the network or k6.

Order matters on shared hardware, so each round ran the six jobs (three builds, read and write) in a shuffled order, with a 5 second warm-up before each 30 second measurement. Three rounds, then the median per job.

The k6 script:

export const options = {
  scenarios: { load: { executor: 'constant-vus', vus: +(__ENV.VUS || 50), duration: __ENV.DUR || '20s' } },
  discardResponseBodies: MODE === 'read',
  summaryTrendStats: ['avg', 'p(50)', 'p(95)', 'p(99)'],
  systemTags: ['status', 'method', 'name', 'scenario', 'expected_response', 'check'],
};

export default function () {
  if (MODE === 'read') {
    const r = http.get(`${BASE}/notes`);
    check(r, { '200': (x) => x.status === 200 });
  } else {
    const r = http.post(`${BASE}/notes`, body, { headers: { 'Content-Type': 'application/json' } });
    check(r, { '201': (x) => x.status === 201 });
    http.del(`${BASE}/notes/${r.json('id')}`, null, { tags: { name: 'DELETE /notes/{id}' } });
  }
}

k6 sent its metrics to Prometheus with -o experimental-prometheus-rw and native histograms, and Grafana drew the dashboards from there.

The url tag trap

The systemTags line is there because of my first attempt. By default k6 tags every request with its URL. Every DELETE /notes/{id} has a new ID, so each request created a new Prometheus series. Within minutes the load machine ran out of memory and the run hung. I threw that run away, wiped the Prometheus data and started again with url removed from the tag list and a fixed name tag on the DELETE. If you send k6 results to Prometheus and your URLs carry IDs, drop the url tag or give those requests a name.

Why the v2 reads were slower

The app serves net/http/pprof on localhost, and I took a 20 second CPU profile in the middle of each round 3 run:

curl -s -o cpu.pprof 'localhost:6060/debug/pprof/profile?seconds=20'
go tool pprof -top -cum cpu.pprof

For the read endpoint, the share of CPU time under syscall.write:

Go 1.26 v1Go 1.27 v1Go 1.27 v2
syscall.write, cumulative9.1%10.0%21.0%
bufio.(*Writer).Flush, cumulative5.4%6.1%27.9%

In the v2 profile the path is MarshalWrite → jsontext.(*encoderState).Flush → net/http.(*chunkWriter).Write → bufio.(*Writer).Flush → syscall.write.

The cause is how often each API calls Write on the io.Writer it gets. A writer that only counts calls shows it:

type countW struct{ n, b int }

func (c *countW) Write(p []byte) (int, error) { c.n++; c.b += len(p); return len(p), nil }

func TestWriteCalls(t *testing.T) {
	var a, b countW
	jsonv1.NewEncoder(&a).Encode(many)
	jsonv2.MarshalWrite(&b, many)
	t.Logf("v1 Encoder: %d Write calls, %d bytes; v2 MarshalWrite: %d Write calls, %d bytes", a.n, a.b, b.n, b.b)
}
v1 Encoder: 1 Write calls, 25536 bytes; v2 MarshalWrite: 22 Write calls, 25535 bytesoutput

The one byte difference is the newline that Encoder.Encode appends and MarshalWrite doesn’t. json.Encoder encodes the whole value into its own buffer and hands it to the writer in one call. MarshalWrite streams: it flushes its buffer into the writer as it goes, 22 times for this list.

net/http holds the first 2 KB of a response body to decide between a Content-Length header and chunked encoding. A 25 KB body doesn’t fit, so both versions are sent chunked, and each chunk goes into the connection’s 4 KB buffered writer. One large Write passes through in a few big pieces. With 22 smaller ones, that buffer is flushed far more often, and each flush is a write system call on the socket. On two CPUs that were already full, the extra system calls came straight out of the request rate.

If you want v2’s encoder with a single write, jsonv2.Marshal returns the bytes and the handler calls w.Write once. I haven’t load tested that variant, so I have no number for it.

Why the writes were close

On the write test, decoding is a small part of the work. Share of CPU in the profiles:

Go 1.26 v1Go 1.27 v1Go 1.27 v2
JSON decoding11.9%10.2%8.8%
system calls (syscall.Syscall, mostly socket reads and writes)36.6%37.0%39.1%

v2’s decoder is the cheapest of the three, and the gap between 11.9% and 8.8% is about the size of its lead in requests per second. Each POST also costs a DELETE, a TCP read and a response write, and those system calls take more than a third of the CPU in every build. That caps how much a faster decoder can show in this test.

What I would check on your own service

Sources


Share this post:

Previous Post
Postgres vs MySQL under load: reads, writes, updates and one open transaction