Building JSON Faster: How V8 Finally Fixed Its String Serialization Strategy
Source: v8
Back in August 2025, the V8 team published a breakdown of how they made JSON.stringify more than twice as fast. Looking back at it now, the work is a clean example of what it takes to meaningfully improve a builtin that has been “good enough” for years: you have to identify which layer of the abstraction stack is actually doing the wasted work, then fix the algorithm at that layer rather than patching around it.
The String Building Problem
JSON.stringify is deceptively simple from the outside. You hand it a value, it returns a string. What happens inside covers significant ground: type dispatch, property enumeration, recursive serialization, string character escaping, cyclic reference detection, and optional replacer function invocation. Each step produces output that must be assembled into a final string.
JavaScript strings are immutable. Every concatenation allocates a new string object, copies both operands into it, and leaves the old strings to be collected by the garbage collector. For a function that builds output incrementally, this is a structural problem. Serializing an object with a dozen properties produces the opening brace, then alternates between keys, colons, values, and commas, before closing. Each piece is a small allocation. Each join creates a larger allocation that immediately discards the smaller ones. For a moderately complex object graph, hundreds of intermediate strings come and go before the final value is materialized.
V8 supports a string representation called ConsStrings (short for concatenation strings), sometimes described as string ropes, where the result of concatenation is stored as a tree of references rather than a flat copy. This defers the copying cost but shifts it to read time. For JSON serialization, which touches every output character exactly once in linear order, a tree of deferred copies is the wrong structure. You want a flat mutable buffer you write into directly.
That architectural shift, from building result strings through repeated concatenation to accumulating output in a pre-allocated byte buffer and materializing the final string once at the end, is the core of what V8 changed. It transforms what was effectively O(n²) memory traffic into O(n). The improvement on workloads with non-trivial output size is not subtle.
String Escaping and the Cost of Per-Character Branching
The JSON specification requires that certain characters be escaped inside string values: control characters from 0x00 through 0x1F, the quotation mark (0x22), and the backslash (0x5C). Unicode surrogates require additional handling for specification conformance.
A straightforward character-by-character implementation tests each character against these ranges with a chain of comparisons or a switch statement. The branch predictor struggles with this pattern on real-world strings, which mix printable ASCII, occasional special characters, and sometimes multibyte sequences in no predictable order.
The standard fix is a 256-entry lookup table indexed by byte value. Each entry indicates whether the corresponding character needs escaping. The entire classification reduces to a single memory read per character. The table fits comfortably in L1 cache, so the access cost is minimal. On top of this, for input strings that are entirely one-byte (ASCII or Latin-1), V8 can scan ahead to find the first character requiring special treatment, rather than inspecting every character one at a time. Long strings with no escape-required characters, which represent the vast majority of string values in typical API payloads, skip the slow path almost entirely.
V8 distinguishes internally between one-byte strings and two-byte (UTF-16) strings. JSON output is almost always ASCII, so representing the output buffer as a one-byte buffer halves its memory footprint compared to a two-byte buffer. Smaller buffers mean better cache utilization during construction, which matters when the output is large.
Hidden Classes and the Property Enumeration Shortcut
V8’s hidden class system (sometimes called “shapes” in SpiderMonkey’s terminology or “maps” in V8’s internal codebase) assigns a shared structural descriptor to objects created with the same sequence of property assignments. This descriptor records the property names and their storage offsets. Objects sharing a hidden class have their properties at predictable locations in memory.
For JSON.stringify, this matters during object serialization. The general path for enumerating an object’s own enumerable properties involves a hash table lookup per property and various checks for inherited properties, non-enumerable properties, and Symbol keys. For objects on the common fast path, objects with a stable hidden class, no Proxy wrapping, no exotic behaviors, and no toJSON method anywhere in the prototype chain, V8 can iterate the hidden class descriptor directly. Property values come from fixed offsets. There is no hash lookup per property.
The 2025 optimization pushed this fast path further. More objects now qualify, and the checks to determine qualification are cheaper. The practical effect is most visible on serializing arrays of uniform objects, the kind of payload you get back from a database query or an API response, where every object has the same hidden class.
The Integer Fast Path for Numbers
JavaScript numbers are IEEE 754 double-precision floats. Converting a double to its shortest decimal representation is a non-trivial operation. V8 uses a variant of the Ryu algorithm for this, which is fast, but still far from cheap.
V8 uses a pointer encoding trick called Smis (Small Integers) for integers that fit in 31 bits. A Smi is stored as a tagged immediate value rather than as a heap-allocated number object. The serializer can detect Smis without dereferencing a heap pointer at all, then convert them to decimal using simple division and remainder operations, bypassing the floating-point machinery entirely. For typical JSON payloads where most numbers are integer IDs, timestamps, or counts, this path covers the large majority of numeric values.
Where This Leaves Schema-Based Serializers
Libraries like fast-json-stringify from the Fastify ecosystem take a fundamentally different approach. They accept a JSON Schema definition and compile a custom serialization function for that specific shape. The compiled function contains no type checking, no property enumeration, no generality whatsoever. It knows exactly which properties exist, in which order, with which types, and generates code that handles nothing else.
Before the 2025 improvements, fast-json-stringify typically showed 2 to 5 times faster serialization than native JSON.stringify for schema-conformant inputs. The native improvement narrows this gap but does not close it. Schema-based serializers eliminate entire categories of work rather than optimizing them. For a server route handling thousands of requests per second with a fixed response structure, the schema approach still wins.
The calculus shifts for general-purpose use: mixed-type data, unknown schemas at write time, one-off serialization, development tooling, and logging. In those contexts, the native implementation is now substantially more competitive than it was, and the maintenance burden of a schema-based approach is harder to justify.
Impact on Node.js and Edge Runtimes
Node.js embeds V8, so this improvement is automatic for any Node.js version shipping the updated engine. No application code changes, no dependency updates. Workloads that serialize substantial response bodies, write to message queues, or persist data to Redis or similar stores will see the gains without any effort.
Edge runtimes like Cloudflare Workers and Deno Deploy run V8 as well. At the edge, CPU time is directly tied to billing and to tail latency. JSON serialization appears in nearly every response path, so a 2x improvement there has compounding value: it reduces the compute cost per request and improves the latency distribution for the cases where serialization was the bottleneck.
The Pattern Behind These Gains
This work fits a recurring V8 optimization pattern: find a high-traffic builtin that has accumulated implementation debt, profile real workloads to locate the dominant cost, and redesign the core algorithm rather than applying patches. Previous examples include the work on Array.prototype.sort, improvements to object spread and rest, and the Maglev compiler tier.
In each case, the bottleneck turned out to be architectural. The fix for JSON.stringify was not a tighter loop or a cleverer branch order. It was recognizing that accumulating output as immutable JavaScript strings, one concatenation at a time, was the wrong structure for the problem, and building a purpose-designed output buffer in its place.
The outcome is measurable, automatic, and arrives without any change to the JavaScript you write.