Glazer: The Fastest JSON/YAML/CSV Codec for Erlang and Elixir

By Serge Aleynikov - August 14, 2026

Why this exists

I work on ad serving at Samsung Ads. The workload is OpenRTB: bid requests come in as JSON over HTTP, get evaluated against campaign rules, and we either send back a bid or a 204. At peak, that’s over a million requests per second.

The auction latency budget is 100-300ms. Our share is roughly 50ms. Within that, you’ve got network RTT, HTTP overhead, business logic, DB lookups, and JSON decode/encode. The JSON part isn’t the biggest slice, but it’s the most wasteful one. It’s pure data transformation with zero business value, and it scales linearly with throughput. At 1M+ req/s, even a 2x improvement frees up real CPU cores.

The problem I was hitting: Erlang’s JSON ecosystem was stuck. Jason (pure Erlang) was 3-4x slower than the NIF options. Jiffy was fine but not fast. My earlier project simdjsone was faster, but simdjson’s strict validation (no duplicate keys, no NaN, specific Unicode handling) made it brittle for real ad tech data where partners send slightly malformed JSON constantly. And the two-phase approach (SIMD validate, then scalar parse) didn’t pay off at the 1-4KB payload sizes that dominate OpenRTB.

So the goal was: build the fastest JSON/YAML/CSV codec that runs as a C++ NIF inside the BEAM, with no external library deps, no intermediate AST, and no validation that rejects real-world data. Then benchmark it honestly against everything, including Go.

What it is

Glazer is a C++ NIF. It decodes and encodes JSON, YAML, and CSV in a single pass, straight to and from native Erlang terms. No AST. No staging step. No external C++ libraries.

The benchmarks are in the repo. The short version:

JSON decode on a 616KB Twitter firehose sample: glazer 1645µs, torque (Rust NIF) 2401µs, simdjsone 2547µs, jiffy 3457µs, jason 5093µs, OTP json 5544µs.

JSON encode on the same file: glazer 700µs, torque 987µs, simdjsone 2023µs, jiffy 1300µs, jason 5043µs.

YAML decode: glazer 18.4µs vs yaml_rustler 104.8µs, fast_yaml 130.7µs, yamerl 1108.5µs.

CSV decode (3.4MB, 25K rows): glazer 20867µs, nimble_csv 144525µs, csv 298011µs, erl_csv timed out.

Why it’s fast

Not because “it’s in C++.” Torque is also a native NIF (Rust, via Rustler, wrapping sonic-rs) and it’s 30-40% slower on both encode and decode. The difference is what happens between the bytes and the Erlang term.

The decoder builds the Erlang term directly as it parses. A JSON object becomes a map. An array becomes a list. A string becomes a binary. There’s no json::Value in between. Most libraries parse into a generic tagged union first, then convert. That’s two passes with an allocation in the middle. Glazer skips that.

It uses AVX2/SSE2 to scan for structural characters in 32-byte chunks, same technique simdjson uses. But unlike simdjson, it doesn’t do a two-phase validate-then-parse. SIMD is only used to skip ahead to the next interesting byte. The actual value extraction is a scalar recursive-descent parser. This means no strict validation requirements, no rejection of duplicate keys or NaN, while still getting the scanning speedup.

For string fields without escape sequences, it returns a zero-copy sub-binary of the input. The BEAM’s reference-counting means the sub-binary shares memory with the parent. No allocation, no copy. For OpenRTB payloads where most fields (device IDs, URLs, campaign IDs) are unescaped, this kills a significant chunk of allocation pressure.

The whole NIF is self-contained in c_src/. No CMake, no vendored libraries, no Rust toolchain. A plain make with GCC 12+ or Clang 16+ builds priv/glazer.so. That matters in practice: it builds cleanly in CI environments that don’t have Rust installed, and the resulting .so has no transitive native dependencies.

There’s also a PGO build target (make pgo) that runs the benchmark suite as the profiling phase and recompiles with the profile. Gets you another 5-10% on the hot paths over plain -O3.

The Go comparison

This is the one that matters for ad tech, because Go is the default for high-throughput ad serving. Most DSPs and exchanges I’ve seen are Go.

The repo has a Go benchmark suite (test/bench-go/run.sh) that runs the same input files through Go’s leading JSON libraries. Here are the numbers:

Library openrtb (1.2K) decode openrtb (1.2K) encode twitter (616K) decode twitter (616K) encode
glazer 2.9 2.2 1645 700
sonic 12.0 7.0 2301 1184
jsoniter 16.0 11.0 4029 1832
goccy/go-json 19.0 13.0 3594 3103
stdlib/json 19.0 16.0 6751 4048

On the OpenRTB payload (1.2KB, the shape that actually matters for auctions), glazer decodes in 2.9µs. The fastest Go option, bytedance/sonic, takes 12µs. That’s 4x. Go’s standard library takes 19µs, which is 6.5x. On encode, glazer at 2.2µs vs sonic at 7µs is 3x.

On the 616KB file, the gap narrows to ~40% on decode and ~70% on encode. The small-payload case is where the architectural difference shows, and the small-payload case is the one that dominates at 1M+ req/s.

Why the gap at small sizes: the Go libraries pay a fixed cost per call that glazer doesn’t. sonic eliminates reflection with CPU-specific codegen, but it still allocates into Go’s global heap, which means every decode triggers GC accounting. At 1.2KB, the actual parsing is trivially small. The overhead around the parse (allocation, GC bookkeeping, interface dispatch) dominates. Glazer’s NIF call builds the term in a single process’s heap with zero-copy sub-binaries, so the fixed cost is lower.

The GC story is where it gets interesting at scale. At 1M req/s with 2KB payloads, you’re allocating ~2 GB/s of decoded data. In Go, all of that goes into one global heap. The GC tracks it, marks it, sweeps it. Even with sub-millisecond STW pauses in Go 1.20+, those pauses stop every goroutine on every core simultaneously. Under sustained allocation pressure, GC frequency goes up and your p99 gets a periodic spike that hits all in-flight requests at once.

In the BEAM, each decoded map lives in one process’s heap. GC is per-process. A GC cycle in the process handling request N is a 50-100µs pause that affects only that process’s mailbox. The other 999,999 processes keep running. The fair scheduler guarantees no one starves. At 1M req/s, this is the difference between “my p99 has a correlated 2ms spike every N seconds” and “my p99 is flat.”

The throughput math: at 1M req/s with 1.2KB payloads, glazer uses 2.9 CPU-seconds per second. Go sonic uses 12. Go stdlib uses 19. That’s a 9-core difference between glazer and sonic, and a 16-core difference between glazer and stdlib. At cloud pricing, that’s real money. At the scale of a major exchange (10M+ req/s), it’s rack units.

One caveat: these are single-threaded per-call benchmarks. If your Go system is already well-parallelized and GC-tuned, the per-call advantage doesn’t automatically become a throughput advantage. But the per-call number determines your p99, and the GC isolation determines your tail latency distribution. For an auction system, both matter.

Features

Beyond speed, the things that make it a drop-in replacement:

Big integers. Numbers that overflow 64 bits decode to bignums and encode back to exact decimal form. Most JSON libraries silently truncate or switch to float. That’s a data corruption bug if you’re dealing with financial data or crypto material.

Streaming decode. stream_decoder/0,1, stream_feed/2, stream_eof/1 for incremental decoding of chunked input. Feed a chunk, get back complete values, decoder retains state. No buffering the whole stream.

Configurable null. JSON null decodes to :null (Phoenix default) or nil (idiomatic Elixir) via the use_nil option. Keys can be atoms or binaries.

Phoenix. glazer_json implements decode!/1, encode!/1, encode_to_iodata!/1. One-line swap:

config :phoenix, :json_library, :glazer_json

jq. glazer_json:query/2,3 runs a jq filter in C++ and only the matching results cross the NIF boundary. If you’re extracting 2% of a large document, that’s a 50x reduction in Erlang term allocation.

minify/1, prettify/1. Single-pass, O(n), no tree.

Benchmarking

make bench-json, make bench-yaml, make bench-csv. Compiles all competitors in the same environment, runs them on the same files, reports median µs. Input files include real OpenRTB bid requests (1.2KB), Twitter firehose samples (616KB, 758KB), and CSV from 1.3KB to 3.4MB. Runs with PARALLEL=2 so the NIF is called from a different process than the one that loaded it.

Run it yourself on your hardware with your data. The relative ordering is consistent, absolute numbers will vary.

What it’s not

Not a serialization framework. No schema validation, no type mapping beyond basic JSON/YAML/CSV types, no streaming encode.

Not a reason to rewrite your Go ad exchange in Erlang. If it’s working, keep it. This is for people choosing a language for a new high-throughput service, or people already in Erlang/Elixir who hit the JSON bottleneck and want to close the gap with Go’s best libraries while keeping the BEAM’s architectural properties (per-process isolation, fair scheduling, hot-loading, always-on observability).

Getting it

def deps do
  [{:glazer, "~> 1.0"}]
end

C++23 compiler (GCC 12+ or Clang 16+), make, that’s it. No other native deps.

GitHub Hex HexDocs

Break it, measure it, tell me what’s slow.