Skip to content

perf: JSON.parse is 50x slower than Node (O(n²) root scanning) #59

Description

@proggeramlug

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_mallocgc_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

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions