Skip to content
Select theme

Performance in real workloads

This first article in the Rust vs Go track measures the thing most arguments skip: the same request handler written twice, run on the same machine, with real numbers for throughput, latency percentiles, and memory per request. No framework, no database, no network — just the work a backend actually does per request (parse some JSON, compute a summary), measured the same way in both languages.

The result is not “Rust fast, Go slow”. It is more useful than that: the number you get depends on which of each language’s parsing paths you reach for, and the two languages do not put their fast path in the same place.

One request is a JSON array of 200 orders — id, customer, category, qty, unit_price — 17,979 bytes in total. The handler parses it and returns total revenue. JSON parsing is the representative backend workload: every API does it, it is CPU-bound, and it stresses the allocator, which is where managed and native runtimes differ most.

Both programs generate the payload from the same seeded generator (a splitmix64 with seed 42), so Rust and Go parse byte-identical input. All three runs in this article end with the same revenue check — 5058491.00 — which is how you know nobody got an easier workload.

The harness runs 8 workers × 250 requests = 2,000 requests. Latency is wall-clock time around one parse-and-summarise call, reported as p50, p95 and max over all 2,000 samples; throughput is total requests divided by total elapsed time. Memory per request counts every byte the allocator hands out while one request runs, averaged over 100 single-threaded requests — cumulative allocation, not peak footprint. That is the same accounting Go’s runtime.ReadMemStats gives through TotalAlloc, and a counting global allocator gives in Rust.

Everything here ran on an Apple M2 Max with rustc 1.99.0 and go1.27.3 darwin/arm64. One run each, shown as it came out; the numbers move a little from run to run and will move more on your machine.

Given a payload and a question (“what is the total revenue?”), the shortest route in Rust is serde_json::Value, the catch-all JSON tree:

src/main.rs
fn process(payload: &str) -> f64 {
let v: serde_json::Value = serde_json::from_str(payload).expect("valid JSON");
let orders = v.as_array().expect("array");
let mut total = 0.0;
for o in orders {
let qty = o["qty"].as_f64().expect("qty");
let price = o["unit_price"].as_f64().expect("price");
total += qty * price;
}
total
}

It works, it is idiomatic, and compiled with optimisations it turns in this:

payload: 200 orders, 17979 bytes
latency µs p50 722 p95 1314 max 2402
throughput 10183 req/s (2000 requests, 8 threads)
allocated per request 150.2 KB
revenue check 5058491.00

10,183 req/s, 722 µs at the median, and 150.2 KB allocated to parse 17.6 KB of input — ten times the input’s size. Value stores every field as an enum behind its own allocation and every object as a map; looking a field up by string (o["qty"]) walks that map. Nothing here is wrong. It is just the generic representation, and the generic representation costs about nineteen times what a typed one costs in allocation.

Hold this number. The Go version is coming, and it beats this.

The fix is to tell serde the shape once and let it decode straight into structs:

Cargo.toml
[package]
name = "perf-check"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

The program only deserialises two of the five fields — unused fields cost nothing, which is one of the quiet advantages of derive over a tree. Compiled with optimisations:

payload: 200 orders, 17979 bytes
latency µs p50 54 p95 68 max 3260
throughput 121256 req/s (2000 requests, 8 threads)
allocated per request 7.9 KB
revenue check 5058491.00

121,256 req/s, a p50 of 54 µs, and 7.9 KB per request.

Go’s standard library has exactly one JSON path: reflection-based encoding/json, decoding into a typed struct. There is no Value tree to fall back on, so the first version a Go developer writes is already the typed one.

main.go
package main
import (
"encoding/json"
"fmt"
"runtime"
"sort"
"sync"
"time"
)
type Order struct {
ID string `json:"id"`
Customer string `json:"customer"`
Category string `json:"category"`
Qty int `json:"qty"`
UnitPrice float64 `json:"unit_price"`
}
func makePayload(orders int) string {
state := uint64(42)
next := func() uint64 {
state += 0x9E3779B97F4A7C15
z := state
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9
z = (z ^ (z >> 27)) * 0x94D049BB133111EB
return z ^ (z >> 31)
}
categories := [4]string{"electronics", "home", "toys", "books"}
body := make([]byte, 0, orders*100)
body = append(body, '[')
for i := 0; i < orders; i++ {
if i > 0 {
body = append(body, ',')
}
cat := categories[next()%4]
qty := 1 + next()%9
price := 0.99 + float64(next()%9900)/100.0
customer := next() % 10000
body = append(body, []byte(fmt.Sprintf(
"{\"id\":\"ord-%06d\",\"customer\":\"cust-%04d\",\"category\":\"%s\",\"qty\":%d,\"unit_price\":%.2f}",
i, customer, cat, qty, price))...)
}
body = append(body, ']')
return string(body)
}
func process(payload string) float64 {
var orders []Order
if err := json.Unmarshal([]byte(payload), &orders); err != nil {
panic(err)
}
total := 0.0
for _, o := range orders {
total += float64(o.Qty) * o.UnitPrice
}
return total
}
func main() {
payload := makePayload(200)
fmt.Printf("payload: %d orders, %d bytes\n", 200, len(payload))
workers, per := 8, 250
var wg sync.WaitGroup
lat := make([]float64, 0, workers*per)
var mu sync.Mutex
started := time.Now()
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
local := make([]float64, 0, per)
for i := 0; i < per; i++ {
t := time.Now()
_ = process(payload)
local = append(local, float64(time.Since(t).Nanoseconds())/1000.0)
}
mu.Lock()
lat = append(lat, local...)
mu.Unlock()
}()
}
wg.Wait()
elapsed := time.Since(started)
sort.Float64s(lat)
n := len(lat)
fmt.Printf("latency µs p50 %.0f p95 %.0f max %.0f\n", lat[n/2], lat[n*95/100], lat[n-1])
fmt.Printf("throughput %.0f req/s (%d requests, %d goroutines)\n", float64(n)/elapsed.Seconds(), n, workers)
runtime.GC()
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
check := 0.0
for i := 0; i < 100; i++ {
check += process(payload)
}
runtime.ReadMemStats(&after)
fmt.Printf("allocated per request %.1f KB\n", float64(after.TotalAlloc-before.TotalAlloc)/100.0/1024.0)
fmt.Printf("revenue check %.2f\n", check)
}
payload: 200 orders, 17979 bytes
latency µs p50 229 p95 606 max 1735
throughput 29528 req/s (2000 requests, 8 goroutines)
allocated per request 61.7 KB
revenue check 5058491.00

The typed Rust numbers above come from cargo run --release. The same program under a plain cargo run — a debug build with no optimisations — manages this:

payload: 200 orders, 17979 bytes
latency µs p50 398 p95 802 max 2640
throughput 16246 req/s (2000 requests, 8 threads)
allocated per request 7.9 KB
revenue check 5058491.00

Seven times slower for the lack of a flag. go run, by contrast, always compiles with optimisations — there is no debug-mode trap to fall into and nothing to forget. Every Rust benchmark whose author does not say --release should be assumed to be this table.

Rust, Value Rust, typed Go, typed
p50 latency 722 µs 54 µs 229 µs
p95 latency 1,314 µs 68 µs 606 µs
throughput 10,183 req/s 121,256 req/s 29,528 req/s
allocated per request 150.2 KB 7.9 KB 61.7 KB

What the table actually says:

  • The typed Rust version beats Go by about 4× on throughput and median latency here, and allocates 8× less per request (7.9 KB against 61.7 KB). Less allocation per request is also less work for a garbage collector to do later.
  • The comfortable Rust version loses to Go by about 3×. Go’s standard library has one JSON path and it is the typed one; Rust has two, and the easiest one to write is the slow one. The comparison you get depends on which language’s default you happened to write.
  • max is scheduler noise, not a property of the language. The typed Rust run’s max of 3,260 µs next to a p95 of 68 µs is one thread getting descheduled once in 2,000 requests. Do not read percentiles above p95 in a 2,000-sample run.

And the honest limit of all of it: this is one CPU-bound workload with no network, no database and no garbage collector under sustained pressure. A service that spends its time waiting on upstream calls will not show a 4× gap — there, the concurrency model matters more than the parse speed, and that is the next article’s subject.

Rust:

Terminal window
cargo new perf-check && cd perf-check
# paste the Cargo.toml dependencies and src/main.rs from above
cargo run --release

Go — no module setup needed, the program is standard library only:

Terminal window
go run main.go

If your numbers differ in shape (not just scale) from the tables above, check the release flag first.

The next article in this track puts the two concurrency models under the same kind of measurement: goroutines and channels versus threads and async, and where the difference between them actually shows up. The overview on the track page lists everything that is coming.