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.
The workload
Section titled “The workload”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.
The first Rust port is the slow one
Section titled “The first Rust port is the slow one”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:
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 byteslatency µs p50 722 p95 1314 max 2402throughput 10183 req/s (2000 requests, 8 threads)allocated per request 150.2 KBrevenue check 5058491.0010,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 typed Rust version
Section titled “The typed Rust version”The fix is to tell serde the shape once and let it decode straight into structs:
[package]name = "perf-check"version = "0.1.0"edition = "2024"
[dependencies]serde = { version = "1", features = ["derive"] }serde_json = "1"use std::alloc::{GlobalAlloc, Layout, System};use std::hint::black_box;use std::sync::atomic::{AtomicUsize, Ordering};use std::sync::Arc;use std::thread;use std::time::Instant;
static ALLOCATED: AtomicUsize = AtomicUsize::new(0);
struct Counting;
unsafe impl GlobalAlloc for Counting { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { ALLOCATED.fetch_add(layout.size(), Ordering::Relaxed); unsafe { System.alloc(layout) } } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { unsafe { System.dealloc(ptr, layout) } }}
#[global_allocator]static GLOBAL: Counting = Counting;
struct SplitMix(u64);
impl SplitMix { fn next(&mut self) -> u64 { self.0 = self.0.wrapping_add(0x9E3779B97F4A7C15); let mut z = self.0; z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); z ^ (z >> 31) }}
const CATEGORIES: [&str; 4] = ["electronics", "home", "toys", "books"];
fn make_payload(orders: usize) -> String { let mut rng = SplitMix(42); let mut body = String::with_capacity(orders * 100); body.push('['); for i in 0..orders { if i > 0 { body.push(','); } let cat = CATEGORIES[(rng.next() % 4) as usize]; let qty = 1 + rng.next() % 9; let price = 0.99 + (rng.next() % 9900) as f64 / 100.0; let customer = rng.next() % 10000; body.push_str(&format!( "{{\"id\":\"ord-{i:06}\",\"customer\":\"cust-{customer:04}\",\"category\":\"{cat}\",\"qty\":{qty},\"unit_price\":{price:.2}}}" )); } body.push(']'); body}
#[derive(serde::Deserialize)]struct Order { qty: u32, unit_price: f64,}
fn process(payload: &str) -> f64 { let orders: Vec<Order> = serde_json::from_str(payload).expect("valid JSON"); orders.iter().map(|o| o.qty as f64 * o.unit_price).sum()}
fn main() { let payload = make_payload(200); println!("payload: 200 orders, {} bytes", payload.len());
let workers: usize = 8; let per_worker: usize = 250; let payload = Arc::new(payload);
let started = Instant::now(); let handles: Vec<_> = (0..workers) .map(|_| { let payload = Arc::clone(&payload); thread::spawn(move || { let mut lat = Vec::with_capacity(per_worker); for _ in 0..per_worker { let t = Instant::now(); black_box(process(&payload)); lat.push(t.elapsed().as_secs_f64() * 1e6); } lat }) }) .collect(); let mut lat: Vec<f64> = Vec::with_capacity(workers * per_worker); for h in handles { lat.extend(h.join().unwrap()); } let elapsed = started.elapsed().as_secs_f64();
lat.sort_by(|a, b| a.partial_cmp(b).unwrap()); let n = lat.len(); println!( "latency µs p50 {:.0} p95 {:.0} max {:.0}", lat[n / 2], lat[n * 95 / 100], lat[n - 1] ); println!( "throughput {:.0} req/s ({} requests, {} threads)", n as f64 / elapsed, n, workers );
ALLOCATED.store(0, Ordering::Relaxed); let mut check = 0.0; for _ in 0..100 { check += process(&payload); } let per_req = ALLOCATED.load(Ordering::Relaxed) as f64 / 100.0; println!("allocated per request {:.1} KB", per_req / 1024.0); println!("revenue check {:.2}", check);}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 byteslatency µs p50 54 p95 68 max 3260throughput 121256 req/s (2000 requests, 8 threads)allocated per request 7.9 KBrevenue check 5058491.00121,256 req/s, a p50 of 54 µs, and 7.9 KB per request.
The Go version
Section titled “The Go version”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.
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 byteslatency µs p50 229 p95 606 max 1735throughput 29528 req/s (2000 requests, 8 goroutines)allocated per request 61.7 KBrevenue check 5058491.00Release build or it didn’t happen
Section titled “Release build or it didn’t happen”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 byteslatency µs p50 398 p95 802 max 2640throughput 16246 req/s (2000 requests, 8 threads)allocated per request 7.9 KBrevenue check 5058491.00Seven 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.
Reading the numbers
Section titled “Reading the numbers”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.
maxis 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.
Run it yourself
Section titled “Run it yourself”Rust:
cargo new perf-check && cd perf-check# paste the Cargo.toml dependencies and src/main.rs from abovecargo run --releaseGo — no module setup needed, the program is standard library only:
go run main.goIf 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.