Summary
JSON.parse on a 10k-element array is 50x slower than Node.js. JSON.stringify is only 2.6x slower, so parse is the dominant bottleneck. The combined roundtrip (parse + stringify, 50 iterations) is 163x slower and uses 7x more RAM (1.3GB vs 180MB).
Benchmark
// Build a ~1MB JSON blob
const items: any[] = [];
for (let i = 0; i < 10000; i++) {
items.push({ id: i, name: "item_" + i, value: i * 3.14 });
}
const blob = JSON.stringify(items);
const start = Date.now();
for (let iter = 0; iter < 50; iter++) {
JSON.parse(blob);
}
console.log("elapsed:", Date.now() - start, "ms");
| Operation |
Perry |
Node |
Ratio |
| JSON.parse only (50 iters) |
8,410ms |
167ms |
50x slower |
| JSON.stringify only (50 iters) |
125ms |
48ms |
2.6x slower |
| Roundtrip (parse+stringify, 50 iters) |
71,626ms |
440ms |
163x slower |
| Metric |
Perry |
Node |
Ratio |
| Peak RSS (roundtrip) |
1,289 MB |
185 MB |
7x more RAM |
Root Cause Analysis
The core issue is O(n²) behavior in json.rs caused by the GC root scanner growing linearly during parse, while every new allocation triggers a scan of all existing roots.
1. PARSE_ROOTS grows linearly during parse (json.rs:71-87)
Every parsed value (string key, string value, number, sub-object, sub-array) is registered in PARSE_ROOTS to prevent the GC from collecting in-progress parse results. For a 10k-element array with 3 fields each, this means ~40k root registrations per parse call.
2. Every allocation triggers root scanning (json.rs, gc.rs)
Each js_string_from_bytes call during parse goes through gc_malloc → gc_check_trigger(). When the trigger fires, it calls scan_parse_roots() which iterates all registered roots. With 40k roots × multiple GC triggers per parse = millions of root iterations.
This is O(n²): n allocations × n roots scanned per GC = n² work.
3. PARSE_KEY_CACHE allocates per key (json.rs:318-329)
PARSE_KEY_CACHE.with(|c| {
c.borrow_mut().insert(key_bytes.to_vec(), ptr); // .to_vec() = heap alloc per key
});
Each unique key string causes a Vec<u8> heap allocation for the cache key. For 10k objects × 3 unique keys, this is 30k Vec allocations on the first parse, plus HashMap growth overhead.
4. Stringify buffer over-allocation (json.rs:956)
estimate_json_size uses fields * 300 which over-allocates by ~10x for typical objects, then the result is copied into a new StringHeader via gc_malloc — two allocations per stringify call.
Suggested Fix Approaches
- Quick win (biggest impact): Disable GC during JSON.parse entirely. Register the final result as a root, not every intermediate. Since parse is synchronous and produces a single result tree, GC during parse only destroys in-progress work.
- Medium: Replace PARSE_ROOTS Vec with a single root pointer to the in-progress parse tree. As each value is attached to its parent array/object, it becomes reachable from the root and doesn't need its own entry.
- Medium: Pre-allocate PARSE_KEY_CACHE entries for common keys (
id, name, value, type, data, etc.) to avoid per-key allocation.
- Stringify: Use a single shared buffer across iterations instead of allocating per call.
Impact
JSON.parse is one of the most frequently called functions in real-world JavaScript — API response handling, configuration loading, IPC, database result deserialization. A 50x slowdown here makes Perry impractical for any data-heavy workload.
Version
v0.5.44
Summary
JSON.parseon a 10k-element array is 50x slower than Node.js.JSON.stringifyis only 2.6x slower, so parse is the dominant bottleneck. The combined roundtrip (parse + stringify, 50 iterations) is 163x slower and uses 7x more RAM (1.3GB vs 180MB).Benchmark
Root Cause Analysis
The core issue is O(n²) behavior in
json.rscaused by the GC root scanner growing linearly during parse, while every new allocation triggers a scan of all existing roots.1. PARSE_ROOTS grows linearly during parse (
json.rs:71-87)Every parsed value (string key, string value, number, sub-object, sub-array) is registered in
PARSE_ROOTSto prevent the GC from collecting in-progress parse results. For a 10k-element array with 3 fields each, this means ~40k root registrations per parse call.2. Every allocation triggers root scanning (
json.rs,gc.rs)Each
js_string_from_bytescall during parse goes throughgc_malloc→gc_check_trigger(). When the trigger fires, it callsscan_parse_roots()which iterates all registered roots. With 40k roots × multiple GC triggers per parse = millions of root iterations.This is O(n²): n allocations × n roots scanned per GC = n² work.
3. PARSE_KEY_CACHE allocates per key (
json.rs:318-329)Each unique key string causes a
Vec<u8>heap allocation for the cache key. For 10k objects × 3 unique keys, this is 30k Vec allocations on the first parse, plus HashMap growth overhead.4. Stringify buffer over-allocation (
json.rs:956)estimate_json_sizeusesfields * 300which over-allocates by ~10x for typical objects, then the result is copied into a newStringHeaderviagc_malloc— two allocations per stringify call.Suggested Fix Approaches
id,name,value,type,data, etc.) to avoid per-key allocation.Impact
JSON.parse is one of the most frequently called functions in real-world JavaScript — API response handling, configuration loading, IPC, database result deserialization. A 50x slowdown here makes Perry impractical for any data-heavy workload.
Version
v0.5.44