Why We Stopped Using JSON.parse in React Native

Wait 5 sec.

:::tipJSON.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.parse at 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 NativeWhen 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), parseFile is 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 everywhereYou mutate the tree heavily in JavaScriptBenchmarks we ran.Fixture sourceAll numbers below use the public ~250 MB file from antonmedv/json-examples:File: data_250mb.jsonRaw 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.Installationyarn add react-native-fast-json react-native-nitro-modules# ornpm install react-native-fast-json react-native-nitro-modules\Requirements:React Native with Nitro Modules set up in your appreact-native-nitro-modules as 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 minutesimport { 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 donefastJson.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 → releaseA 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 foreverAvoid 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:parseFile when you need dataCopy primitives / small plain JS objects into stateDrop JsonView references and call release(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 ||----|----|| < ~10 MB | 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 behaviorparseFile(path) returns a cached root for the same path until release(path).parseString is not path-cached; each call allocates anew until JS drops the handle.Expensive escapesasObject() and rawJson() 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.