Latency tails, allocations, and GC
Problem
Mean serialize time looks fine while p99 collapses under load. p99 is the 99th-percentile latency. It is the time below which 99% of requests finish. Managed runtimes pay for allocation rate with garbage-collection (GC) pauses. Native heaps pay with allocator contention and cache misses.
Garbage collection is automatic reclaiming of memory that a program no longer uses. When the collector runs, application threads may pause. That shows up as rare but large latency spikes. Those spikes are the tail of the distribution.
Codecs that “win” microbenchmarks by allocating per field can lose the reliability target (service-level objective; for example, 99% of requests finish within 200 ms). Charts that show only means hide the failure mode. This page teaches you to treat tails and allocations as first-class evidence.
Short answer
Treat allocation and copy behavior as first-class when choosing among implementations in a family. See implementation variance. Prefer APIs that reuse buffers, stream, or reduce temporary strings when p99 matters. Interpret suite means as a starting point. Validate under concurrency with your runtime GC settings and payload shape. See 201 encode/decode cost. Format brand does not determine GC pressure. Implementation and shape do.
In other words: a library that is slightly slower on the mean but allocates far less can win the production latency budget.
Constraints that matter
| Factor | Effect on tails |
|---|---|
| Allocations per message | GC work or allocator work |
| Payload shape | Deep graphs create many objects |
| Concurrency | Parallel allocate can cluster pauses |
| Buffer reuse | Lowers steady-state pressure |
| Native versus managed | Different pause mechanics, same lesson: do not thrash the allocator |
This matters because two JSON libraries can look similar on mean decode. They can diverge sharply on p99 once worker threads allocate in parallel.
Decision frame
Is the reliability target (SLO) p99 or p999 under load?
→ inspect allocations, pooling, and streaming options
→ load-test; do not stop at mean Dashboard numbers
Is this a mean-only nightly batch job?
→ means may suffice; still watch the memory ceiling
| Signal | Action |
|---|---|
| High allocations per operation in the profiler | Try an alternate library or object reuse |
| GC pause correlates with traffic | Reduce how chatty decode is |
| Size looks fine, latency looks bad | Suspect the CPU or allocation path, not the network |
A profiler is a tool that shows where a program spends time and memory. Use one before you rewrite a format.
Failure modes
| Mistake | Outcome |
|---|---|
| Optimizing mean only | p99 pages at peak traffic |
| Ignoring payload shape | Microbenchmarks on tiny structs mislead |
| Cross-language GC comparison | Invalid basis for format choice |
| Disabling GC in the bench | Fantasy numbers |
| Pooling without clear ownership | Use-after-free and data races |
For example, turning GC off in a microbenchmark can make a library look impossibly fast. It teaches nothing about production.
Real-world sketch
Two JSON libraries show similar mean decode on the Python Dashboard. Production p99 diverges. One builds full dict trees. Another binds into typed objects with fewer temporary strings. A load test with production-shaped payloads and workers decides the pin. The mean column alone does not.
In this suite
| Resource | Role |
|---|---|
| Dashboard means and ops | Orientation within one language |
| Methodology | Warmup and outliers—read before quoting |
| Optional memory metrics | If present for a language, use them cautiously |
| Using this suite | Fair slice checklist |
Many published tables emphasize central tendency. You still owe a concurrent validation.
Experiments
Question: Under production-shaped load, is encode/decode allocation pressure (not mean time alone) driving p99 risk for candidate libraries in the same family?
Setup
- Fix one language, one paradigm family, and one fixture close to production shape. One example is a deep graph versus dense
Telemetry. See using this suite. - Shortlist two or three implementations from the language Dashboard slice. Stay in the same family. Note versions.
- Confirm the benchmark runner reports useful signals. Or attach wall times yourself. Optionally attach
MemoryPeakBytesor tracemalloc (Python). Also attach a process profiler for allocation rate and GC pauses outside pure means. - Configure a load path that reuses your service concurrency model. Include workers and pool sizes. Do not rely only on single-threaded suite loops.
Procedure
- Run the suite slice for candidates. Record mean and median serialize, deserialize, and total time. Record size and fidelity.
- If available, record
mean_memory_peak_bytesor peak allocation columns. - Load-test or profile each candidate on the same fixture at target concurrency. Capture p99/p999 latency and GC and allocation stats from the runtime.
- Optionally disable “fantasy” modes only as a diagnostic. One example is GC off. Do not use that as the decision number.
- Apply the decision rule below. Pin library and version.
Decision rule
- Prefer the candidate that meets the p99 reliability target with acceptable allocation and GC behavior. That holds even if mean is slightly worse.
- Reject candidates that win mean Dashboard ranks but show high allocations per operation. Also reject GC pause clustering under load.
- Do not compare GC metrics across languages to choose a format brand.
Metrics
Primary signals for this page’s decision. See also Metrics catalog:
| Metric / signal | Where | Role |
|---|---|---|
| p99 / p999 latency (serialize, deserialize, or end-to-end) | Load test or APM | Primary. Tails are the reliability target. |
total_median_ns / ser_median_ns / deser_median_ns |
Suite analysis | Orientation within language; not sufficient alone |
total_mean_ns, avg_ops_per_sec |
Suite | Central tendency; easy to over-trust |
total_p95_ns / total_p99_ns (if computed) |
Suite or full metrics profile | Bridge from benchmark runner to tails when available |
total_std_ns / CV / MAD |
Suite | Dispersion hint; not production p99 |
mean_memory_peak_bytes / MemoryPeakBytes |
Suite (optional) | Allocation-pressure proxy when present |
| Allocations per op / alloc rate | Profiler | Explains GC pressure |
| GC pause time / frequency | Runtime metrics | Direct tail mechanism on managed runtimes |
median_size_bytes |
Suite | Separates “big payload” from “allocation-heavy codec” |
mean_fidelity |
Suite | Reject broken codecs before the performance debate |
Conclusion style: “Choose library L because p99 and allocation rate under load meet the reliability target; mean Dashboard ranks only shortlisted L.”
Not decision metrics here: cross-language Dashboard ranks; format brand alone.
What this suite cannot tell you
- p99 under your framework and GC flags.
- Interaction with other allocators on the host.
- Whether pooling is safe in your concurrency model.
Common mistakes
- Claiming “binary always means lower GC” without measuring.
- Comparing C# and Python pause behavior to choose a format brand.
- Shipping the fastest mean library that allocates unbounded on hostile input. See untrusted input.
Key takeaways
- Tails track allocations and shape. They do not track slogans.
- Means are necessary for latency reliability targets. They are not sufficient.
- Pick implementations with runtime behavior in mind.
- Confirm under load outside the benchmark runner.