DEV Community

Cover image for Parsing hundreds of megabytes of JSON in milliseconds in React Native
running squirrel
running squirrel

Posted on

Parsing hundreds of megabytes of JSON in milliseconds in React Native

How JSON.parse materializes entire documents in JavaScript, and how react-native-fast-json uses native simdjson + lazy JsonView access to change the cost model in React Native.

react-native-fast-json is an open-source React Native library that wraps simdjson in C++ and exposes a lazy JsonView through Nitro Modules.

Most teams treat JSON parsing as “cheap” because API responses are small. The mental model is:

bytes on wire → JSON.parse → plain objects in JS → app logic
Enter fullscreen mode Exit fullscreen mode

That model breaks when the payload is hundreds of megabytes. The dominant cost is not “JSON syntax is slow” in the abstract, it is allocating and wiring up a full value tree in the JavaScript heap.


What JSON.parse actually does in React Native

When you call JSON.parse (or await response.json()), the engine:

  1. Holds the UTF-16/UTF-8 string (or an internal representation of the body)
  2. Walks the entire document
  3. For every object, array, number, boolean, and string, creates JavaScript values with engine overhead (hidden classes, property storage, array backing stores, interned strings, etc.)

For a ~250 MB file, outcomes depend heavily on platform and available RAM:

Symptom Typical cause
Multi-second freezes Single-threaded parse + massive allocation on the JS thread
Memory far above file size JS object graph overhead (often ~2–4× the raw JSON size in bad cases)
OOM / process kill Peak JS heap + retained tree + rest of the app

On the data_250mb.json fixture in our tests: iOS could complete JSON.parse but only with extreme CPU and memory spikes; Android always crashed before parse finished, making JSON.parse impossible for that payload on Android, while native parseFile worked on both platforms.

Benchmark fixture (source)

Measurements in this repo use the public ~250 MB JSON from antonmedv/json-examples:

Repository https://github.com/antonmedv/json-examples
File data_250mb.json
Raw URL https://github.com/antonmedv/json-examples/raw/refs/heads/master/data_250mb.json

The example app downloads that URL, caches it on disk, then calls parseFile on the local path.

Measured results (parseFile, release builds, physical devices)

Platform Time to root JsonView CPU / memory (observed)
iOS ~100 ms Stable, no long CPU peg, no runaway JS heap growth during parse
Android ~500 ms Stable, same qualitative behavior as iOS, slower wall time

JSON.parse / response.json() on the same fixture:

Platform JSON.parse parseFile + lazy JsonView
iOS Many seconds; extreme CPU and memory spikes (~1.2 GB JS heap ballpark) ~100 ms; CPU and memory stable
Android Crash, could not complete parse ~500 ms; CPU and memory stable (~400 MB mostly native buffer)

The second row is not “free memory.” The file still occupies roughly file size + simdjson padding in native memory. The win is avoiding a second full copy as a giant JS object graph until you explicitly materialize pieces.


Why “read file as string, then JSON.parse” can be worse at peak

A common variant:

const text = await readFile(path, 'utf8');
const data = JSON.parse(text);
Enter fullscreen mode Exit fullscreen mode

At peak you may briefly hold:

  • The entire file as one JS string
  • The entire parsed tree
  • Parser scratch allocations

response.json() can sometimes avoid exposing one giant application-level string, but the end state is still the same: a full tree in JS. For large files, peak heap is the killer.


The technique: how react-native-fast-json does it

react-native-fast-json is what makes the native lazy model practical in React Native. Instead of returning a plain object from JSON.parse, it shifts work across a different boundary:

file bytes → native buffer (simdjson) → JsonView handle → read paths on demand
Enter fullscreen mode Exit fullscreen mode

1. Native buffer (simdjson)

parseFile loads the file into a padded native buffer (simdjson::padded_string::load). Parsing runs in C++ with simdjson (SIMD-accelerated, widely used for large JSON).

The document’s canonical bytes live in native memory, not as nested JS objects.

2. Lazy JsonView (Nitro hybrid object)

The root returned to JavaScript is a JsonView, a Nitro hybrid object backed by C++. Calling:

  • getValue('metadata')
  • atPath('$.a.b.c')
  • asString() on a scalar

…does targeted native work and only crosses the bridge with small results (another JsonView handle or a primitive), not the whole document.

3. Materialization is explicit and expensive

When you call:

  • asObject(), subtree becomes a JS-friendly map/array structure
  • rawJson(), subtree becomes a JS string

…you have chosen to pay the JS heap cost for that slice. On a 250 MB document, calling these on the root is equivalent to opting back into the problem you avoided.


Architecture

HybridFastJson (cpp/HybridFastJson.cpp):

  • parseFile(path), load file, construct HybridJsonView, cache by path string
  • parseString(str), copy string into native view (not path-cached)
  • release(path), erase cache entry for that path

HybridJsonView, navigation, path helpers, scalar coercion, optional materialization.

Until release(path), a cached parseFile keeps the full native buffer alive even if JS drops its JsonView reference, plan releases when the flow ends.


What you still pay for (honest accounting)

Cost Still there?
File-sized native memory Yes, roughly JSON size + padding
Parse CPU Yes, but native simdjson is much faster than building a JS tree
JS heap for the whole tree No, unless you asObject() / rawJson() large parts
Bridge traffic Per access; keep reads coarse-grained for huge docs
Path cache parseFile retains buffer until release(path)

There is no zero memory overhead. The claim that holds up is: near-zero additional JS heap for the full document while you navigate lazily.


Why milliseconds (or low hundreds of ms) are plausible for hundreds of MB

simdjson is designed for throughput on large valid JSON. On the data_250mb.json fixture, parseFile after the file is on disk landed at ~100 ms on iOS and ~500 ms on Android in our release builds, with stable CPU and memory. By contrast, JSON.parse on iOS took many seconds with extreme spikes; on Android it always crashed, so native parse was the only viable option there.

Caveats when you reproduce benchmarks:

  • Fixture, use the same public file or disclose yours
  • Cold vs warm storage (first read after download vs re-parse)
  • Platform, Android was ~5× slower than iOS in our tests; do not assume one number for all devices
  • Device tier and storage speed
  • JSON shape (depth, string density) affects constant factors
  • Debug vs release native builds, ship measurements on release binaries

Comparison table (same fixture, measured + observed)

Dimension JSON.parse (our tests) Native JsonView (parseFile)
iOS Completes after many seconds; extreme CPU/memory spikes ~100 ms; stable CPU/memory
Android Crash, parse not possible on ~250 MB ~500 ms; stable CPU/memory
Primary memory Full JS object graph (when it survives) Native padded buffer (~file size)
UI thread risk High during parse Lower; still avoid huge sync work in JS
Best access pattern Whole-tree mutation Few paths / scalars / wildcards
Foot-guns Retaining whole data asObject() / rawJson() on huge nodes; never calling release

Wildcards and paths

  • atPath('$.a.b.c'), simple dotted segments, no [index] in the path string.
  • atPathWithWildcard('$.items[*].id'), dynamic segments; returns string[] | null for matched scalar strings.

For very large documents, prefer narrow paths over scanning wildcards across the whole tree unless you have measured the cost.


When native lazy parsing is the wrong tool

Not a common use case, prefer better alternatives

Shipping ~250 MB JSON to a phone is a smell, not a pattern. We benchmark it because it stress-tests the parser and matches real legacy pain, not because every app should do it.

Try these first (usually cheaper than optimizing parse):

  1. Don’t send the file, paginate, stream, or return only fields the UI needs.
  2. Don’t use JSON on the wire, schema-driven binary formats decode faster and smaller.
  3. Don’t keep one blob, SQLite (or similar) with indexes for the queries you actually run.
  4. Don’t parse on the critical path, generate summaries or shards on the server; sync deltas.

Native lazy parsing is a mitigation when you are locked into large JSON. It is not a substitute for a sane data contract.

When this library still does not help enough

  • Small payloads, JSON.parse is simpler and fast enough.
  • You need the entire document in JS anyway, you will materialize everything eventually; measure whether native parse + bulk asObject() is still a win.
  • Heavy in-place mutation of arbitrary subtrees in JS, work on plain objects.
  • You cannot hold file-sized native RAM (low-RAM devices + multiple huge files), budget and serialize access; even native buffers are ~file size.

Benchmark methodology

import { fastJson } from 'react-native-fast-json';

const root = await fastJson.parseFile('/path/to/large.json');
const version = root?.getValue('metadata')?.getValue('version')?.asString();
fastJson.release('/path/to/large.json');
Enter fullscreen mode Exit fullscreen mode
  1. Fixture, data_250mb.json from antonmedv/json-examples, via https://github.com/antonmedv/json-examples/raw/refs/heads/master/data_250mb.json
  2. RN Version 0.85.0, New Architecture.
  3. Device, physical iOS and Android (document model, OS, RN version, Hermes on/off).
  4. Build, release native binary.
  5. Scenarios
    • A: response.json() or JSON.parse after full string in memory
    • B: download/cache to disk → parseFile(localPath) → read N fields → release(path)
  6. Metrics, wall time to root JsonView, JS heap snapshot, CPU/memory stability in Instruments / Android Studio.
  7. Our results, parseFile: iOS ~100 ms, Android ~500 ms, CPU/memory stable. JSON.parse: iOS many seconds with extreme spikes; Android always crashed on this fixture.

The example app implements download + parseFile + timing UI, use it as a starting point, not as universal truth.


Summary

Idea Detail
Problem JSON.parse materializes the whole document in the JS heap.
Technique Parse in native with simdjson; expose JsonView; read lazily.
Speed data_250mb.json: ~100 ms (iOS) / ~500 ms (Android) for parseFile in our tests.
CPU / memory Stable on native parse; JSON.parse had extreme spikes on iOS and crashed on Android for this fixture.
Memory model Avoid blowing up the JS heap; native still holds ~file size.
Discipline release(path), avoid huge asObject() / rawJson(), extract scalars to plain JS when done.

Try react-native-fast-json

yarn add react-native-fast-json react-native-nitro-modules
Enter fullscreen mode Exit fullscreen mode

Install pods, rebuild, then use fastJson.parseFile for large files on disk. Peer dependency: react-native-nitro-modules.

Top comments (0)