:::tip
JSON.parse hit a limit we needed to go past, so we abandoned it and used a simdjson wrapper.
:::
This article provides a practical guide to react-native-fast-json, when to use it, how to install it, and patterns that keep memory under control.
The problem we hit.
Our app needed to read a ~250 MBJSON offline bundle (catalog, config dump, similar). The flow looked innocent: n
const response = await fetch(url);
const data = await response.json();
On real devices with the ~250 MB data_250mb.json fixture:
- iOS,
JSON.parse/response.json()eventually finished, but only after many seconds with extreme CPU and memory spikes (UI frozen, heap ballooning). - Android, the app always crashed; we could not parse this file with
JSON.parseat all.
JSON.parse (and response.json(), which ends in the same place) is fine for API-sized payloads. For hundreds of megabytes, it builds a full JavaScript object tree, every object, array, string key, and string value, in the JS heap. That is the bottleneck we wanted to escape.
We switched to react-native-fast-json: parse in native code with simdjson, expose a lazy JsonView, and only pull the fields we need into JavaScript.
Want the “why” behind native lazy parsing?
n Read The Hidden JS Heap Cost of JSON.parse in React Native
When this library fits (and when it does not).
Use it when:
- JSON is large (roughly ~100–200 MB+, or anything that already hurts or crashes with
JSON.parse) - Access is mostly read-only and targeted (a few keys, paths, scalars, or wildcard queries)
- The file is already on disk (or you can cache it there),
parseFileis the happy path
Stick with JSON.parse when:
- Payloads are small or medium (KB to a few MB)
- You need the entire document as plain JS objects/arrays everywhere
- You mutate the tree heavily in JavaScript
Benchmarks we ran.
Fixture source
All numbers below use the public ~250 MB file from antonmedv/json-examples:
- File:
data_250mb.json - Raw download URL (used in the example app):
https://github.com/antonmedv/json-examples/raw/refs/heads/master/data_250mb.json
We downloaded it once, cached it on disk (e.g., with react-native-nitro-cache), then timed fastJson.parseFile(localPath) on release builds on physical iOS and Android devices.
parseFile parse time (measured)
| Platform | Time to root JsonView (approx.) |
|—-|—-|
| iOS | ~100 ms |
| Android | ~500 ms |
parseFile was slower on Android than iOS (storage/CPU differences are normal), but it completed reliably on both platforms.
Compared to JSON.parse / response.json() (same fixture)
| Platform | JSON.parse / response.json() | parseFile + root JsonView |
|—-|—-|—-|
| iOS | Succeeded after many seconds; extreme CPU and memory spikes (~1.2 GB JS heap ballpark) | ~100 ms; CPU and memory stable |
| Android | Always crashed, parse not possible | ~500 ms; CPU and memory stable (~400 MB mostly native buffer) |
On Android, native parse was not just faster; it was the only approach that worked for this file size in our tests.
See the technical article for why native memory is not zero overhead, and for full benchmark methodology.
Installation
yarn add react-native-fast-json react-native-nitro-modules
# or
npm install react-native-fast-json react-native-nitro-modules
Requirements:
- React Native with Nitro Modules set up in your app
react-native-nitro-modulesas a peer dependency
iOS, from your app root: n
cd ios && pod install
Rebuild the native app after adding the dependency (Android and iOS).
Core API in five minutes
import { fastJson, type JsonView } from 'react-native-fast-json';
// Preferred: load from a filesystem path (cached by path until release)
const root = await fastJson.parseFile('/path/to/data.json');
// Alternative: parse a string (not cached by path; duplicates cost memory)
const fromString = await fastJson.parseString(jsonString);
// Drop file cache when done
fastJson.release('/path/to/data.json');
Navigating with JsonView
| Method/property | Purpose |
|—-|—-|
| getValue(key) | One object key (not a path). Returns JsonView |
| keys() | Keys of an object/array |
| at(index) | Array element by index |
| atPath('$.a.b.c') | Dotted path from $ (no [index] segments) |
| atPathWithWildcard('$.items[*].id') | Wildcards/indices; returns string[] |
| type, length | Value kind and length |
| asString(), asNumber(), asBoolean() | Scalars |
| asObject() | Materialize subtree to JS (expensive on large values) |
| rawJson() | Raw JSON slice as string (expensive on large values) |
Example, read a few fields without building a full tree in JS: n
const root = await fastJson.parseFile(path);
if (!root) return;
const version = root.getValue('metadata')?.getValue('version')?.asString();
const batchSize = root
.atPath('$.metadata.configuration.export_settings.batch_size')
?.asNumber();
End-to-end pattern: download → file → parse → extract → release
A pattern that worked well in our example app:
- Cache the file on disk (we used react-native-nitro-cache; any reliable download-to-path flow is fine).
parseFile(localPath)once you have the path.- Read only what you need via
getValue/atPath/ scalars. release(localPath)when the screen or flow ends.
import { fastJson } from 'react-native-fast-json';
import { rnNitroCache } from 'react-native-nitro-cache';
const REMOTE_URL = 'https://example.com/huge.json';
async function loadMetadata() {
const cached = await rnNitroCache.getOrFetch(REMOTE_URL);
const path = cached?.url ?? '';
if (!path) throw new Error('No cached file');
const root = await fastJson.parseFile(path);
try {
const metadata = root?.getValue('metadata');
return {
version: metadata?.getValue('version')?.asString(),
keys: metadata?.keys(),
};
} finally {
fastJson.release(path);
}
}
You pay the large native buffer only while extracting, not for the whole app session.
Memory habits that matter.
Do not keep the root JsonView forever
Avoid storing the root in React state, context, or a singleton for the app lifetime unless you truly need random access to the whole document for a long time.
Better:
parseFilewhen you need data- Copy primitives / small plain JS objects into state
- Drop
JsonViewreferences and callrelease(path)
async function loadConfig(path: string) {
const root = await fastJson.parseFile(path);
try {
const version = root?.getValue('metadata')?.getValue('version')?.asString();
const batchSize = root
?.atPath('$.metadata.configuration.export_settings.batch_size')
?.asNumber();
return { version, batchSize };
} finally {
fastJson.release(path);
}
}
Rough size guide
| File size | Guidance |
|—-|—-|
| | Usually OK to keep a root for a screen session |
| ~10–50 MB | Treat as a large native allocation; avoid overlapping parses |
| ~50–200 MB+ | Short-lived root, release soon, avoid rawJson / asObject on huge subtrees |
Caching behavior
parseFile(path)returns a cached root for the samepathuntilrelease(path).parseStringis not path-cached; each call allocates anew until JS drops the handle.
Expensive escapes
asObject()andrawJson()on large subtrees can allocate heavily on the JS side, use for small slices only.
API Cheat Sheet
| Method | Description |
|—-|—-|
| parseString(str) | Parse from string (not path-cached) |
| parseFile(path) | Load + parse file; cached by path |
| release(path) | Remove cached parse for path |
Measuring in your app.
Use the same fixture (data_250mb.json) and compare:
fetch→response.json()(or read file →JSON.parse)- Download/cache →
parseFile→ read a few keys →release
Log wall time and watch memory in Xcode Instruments/Android Studio Profiler. Try JSON.parse on Android explicitly for your largest payloads, our ~250 MB fixture crashed there but completed on iOS (with extreme spikes). Publish device model and OS version if you share numbers publicly.