Skip to content
matthewcm.dev

    ↑ ↓ move · ↵ open · tab scopeopen graph →

    The Fastest JSON Parser Can Still Be Too Slow

    On a high-rate data path in a garbage-collected runtime, the parser that wins the benchmark can lose at p99: building a full object for every message creates garbage-collection pauses. A streaming, low-allocation approach, how to measure it honestly, and why it isn't the default.

    Seedtended planted 3 min read

    🌱 A rough idea, planted recently. Expect rough edges — it will grow.

    On this page

    Parsing JSON is a solved problem, until it isn’t.

    Picture a network automation platform. Thousands of devices stream telemetry and events as JSON: interface counters, link state changes, alarms. Each message is small, but they never stop, and they fan out to several consumers: dashboards, alerting, and automation that reacts on its own, rerouting traffic or opening an incident. When a link flaps, the time between the event arriving and the automation acting on it is the number that matters.

    The obvious move is to reach for the fastest JSON library you can find. On a benchmark it’s excellent. On this kind of path, it can still be the bottleneck.

    The benchmark measures the wrong thing

    A typical JSON parser does what you’d want for most code: it turns the whole message into an object tree. In a garbage-collected runtime, every one of those objects is an allocation, and at a high message rate that means a constant churn of short-lived objects. Eventually the garbage collector has to clean up, and while it does, the messages behind it wait.

    That’s the trap. A throughput benchmark parses a big file in a tight loop and reports an average. A telemetry stream is the opposite shape: many small messages, where what matters is the worst few percent. The microseconds a fast parser saves on each message can be wiped out by one garbage-collection pause that holds up everything behind it, including the alarm the automation was waiting for.

    Fast on average and low-latency are not the same property.

    Parse less, allocate less

    The alternative is a streaming parser that reads through each message once and extracts only the fields the consumers actually use, with as little allocation as possible: no full object tree, reusable buffers, and values written straight into the shapes the rest of the pipeline needs. Most of a message is skipped rather than materialised. A link-state event might carry dozens of fields; the automation needs four.

    It’s more work than calling a library. The parser has to handle the message as a stream, cope with fields arriving in any order, and fail safely on malformed input, all without the conveniences that allocation normally buys you.

    Measuring it honestly

    Benchmarks are where this kind of work goes wrong, so the rules need to be strict (the same lesson as Rust and WebAssembly: Benchmark Before You Bet):

    • Compare against the field, not a straw man: a broad set of existing parsers, including the fastest ones.
    • Production-like load: realistic message shapes and rates, for long enough that the garbage collector actually runs.
    • Measure p99, not the average. Averages hide exactly the spikes this is meant to remove.
    • Measure garbage-collection pause time and memory, not just parse time.

    And write the definition of done down before you start: for example, garbage-collection pauses disappear from the hot path, and p99 beats the best library under the same load. Deciding the target first is what stops a performance project from turning into an open-ended tuning hobby.

    Would I do this everywhere? No

    A bespoke parser is code someone has to maintain forever: edge cases, malformed input, new fields when a vendor changes its telemetry format. It only earns its place on a proven hot path, where you’ve profiled and the latency budget genuinely hurts, like automation that has to react to a failing link in real time. In most products, a few milliseconds rarely change an outcome, and a well-supported library is the right default.

    The habit worth keeping isn’t “write your own parser”. It’s: profile first, measure the tail, and count allocations as a cost, especially on paths that process data continuously, like the ones in Snapshot and Delta Sync over WebSockets or The Node.js Event Loop, Streams and Backpressure.

    • #performance
    • #json
    • #garbage-collection
    • #latency
    • #p99
    • #benchmarking
    • #streaming
    • #network-automation
    • #telemetry

    Nothing links here yet — it’s a fresh shoot.

    ↪ Links to (3)