for safe SIMD boundary reads. Instead of copying the file into a JavaScript string, the buffer is allocated in native memory. This eliminates the initial string-to-heap conversion and provides a contiguous memory region optimized for parallel parsing.
2. Hybrid Object Bridging
React Native's bridge introduces serialization overhead when transferring complex structures. To avoid this, the library uses Nitro Modules to expose a C++ HybridJsonView directly to JavaScript. The root handle returned to the JS runtime is not a plain object; it is a proxy backed by native memory. Method calls on this proxy execute in C++, perform targeted navigation, and return only the requested slice. This architecture minimizes bridge traffic and prevents accidental full-tree serialization.
3. Lazy Traversal & Explicit Materialization
Navigation methods like atPath or getValue resolve JSON pointers or dotted paths within the native buffer. Scalar extraction (asString, asNumber) converts only the matched node to a JavaScript primitive. Subtree materialization (asObject, rawJson) is deliberately expensive. It forces the engine to allocate a JavaScript object graph for the requested slice. By making materialization explicit, developers control exactly when and where heap allocation occurs.
Implementation Example
The following TypeScript wrapper demonstrates the architecture with renamed interfaces and a production-ready structure. It abstracts the native module calls while enforcing safe resource management.
import { NativeModules, NativeEventEmitter } from 'react-native';
// Native module interface (backed by Nitro + simdjson)
interface NativeFastJsonModule {
loadDocument(filePath: string): Promise<string>; // Returns native handle ID
resolvePath(handleId: string, jsonPointer: string): Promise<unknown>;
extractScalar(handleId: string, jsonPointer: string, type: 'string' | 'number' | 'boolean'): Promise<unknown>;
materializeSubtree(handleId: string, jsonPointer: string): Promise<Record<string, unknown> | unknown[]>;
disposeDocument(handleId: string): void;
}
const FastJsonNative = NativeModules.FastJsonModule as NativeFastJsonModule;
export class LazyJsonReader {
private handleId: string | null = null;
private filePath: string;
constructor(filePath: string) {
this.filePath = filePath;
}
async initialize(): Promise<void> {
if (this.handleId) throw new Error('Document already initialized');
this.handleId = await FastJsonNative.loadDocument(this.filePath);
}
async getScalar<T extends string | number | boolean>(
path: string,
type: T extends string ? 'string' : T extends number ? 'number' : 'boolean'
): Promise<T | null> {
this.assertReady();
const raw = await FastJsonNative.extractScalar(this.handleId!, path, type);
return (raw as T) ?? null;
}
async getSubtree(path: string): Promise<Record<string, unknown> | unknown[] | null> {
this.assertReady();
const raw = await FastJsonNative.materializeSubtree(this.handleId!, path);
return (raw as Record<string, unknown> | unknown[]) ?? null;
}
async resolvePointer(path: string): Promise<unknown> {
this.assertReady();
return FastJsonNative.resolvePath(this.handleId!, path);
}
cleanup(): void {
if (this.handleId) {
FastJsonNative.disposeDocument(this.handleId);
this.handleId = null;
}
}
private assertReady(): void {
if (!this.handleId) throw new Error('Call initialize() before accessing document');
}
}
Architecture Rationale
- Why Nitro Modules? Traditional TurboModules require manual bridge serialization for complex return types. Nitro generates type-safe C++/Swift/Kotlin bindings that expose hybrid objects directly to JavaScript, eliminating intermediate serialization steps.
- Why simdjson? Standard parsers validate JSON sequentially. simdjson uses SIMD instructions to process 16+ bytes per cycle, achieving multi-gigabyte throughput. It is the industry standard for high-performance JSON parsing in C++ ecosystems.
- Why explicit disposal? Native buffers are not garbage-collected by the JavaScript runtime. Without deterministic cleanup, repeated parsing operations leak native memory proportional to the file size. The
disposeDocument call ensures the padded buffer is freed immediately after use.
Pitfall Guide
1. Omitting Deterministic Cleanup
Explanation: Native buffers allocated by loadDocument persist in native memory until explicitly freed. JavaScript garbage collection does not track native allocations. Forgetting to call disposeDocument causes cumulative memory leaks that eventually trigger OOM crashes.
Fix: Wrap reader usage in a try/finally block or implement a resource manager that guarantees cleanup regardless of execution path.
2. Materializing the Root Node
Explanation: Calling materializeSubtree on $ or the root path forces the entire document into the JavaScript heap. This reproduces the exact OOM condition the native approach was designed to avoid.
Fix: Only materialize deeply nested slices required for UI rendering. Keep navigation lazy until the final transformation step.
3. Bridge Thrashing via Fine-Grained Loops
Explanation: Each resolvePath or extractScalar call crosses the JavaScript-to-native bridge. Iterating over thousands of items with individual path calls introduces significant latency and CPU overhead.
Fix: Use wildcard resolution (atPathWithWildcard equivalent) to batch extractions, or materialize a parent array and iterate in JavaScript. Measure bridge round-trip costs before choosing a strategy.
4. Assuming Zero Memory Overhead
Explanation: The native approach avoids JavaScript heap allocation but still requires native memory roughly equal to the file size plus simdjson padding (~10β15%). On low-RAM devices, holding multiple large documents simultaneously will exhaust system memory.
Fix: Implement a document pool with strict concurrency limits. Serialize access to large files and release buffers immediately after processing.
5. Applying to Small Payloads
Explanation: The overhead of native module invocation, buffer allocation, and hybrid object creation outweighs the benefits for payloads under 5 MB. Standard JSON.parse is highly optimized for small documents and executes faster with less complexity.
Fix: Implement a size threshold. Use native lazy parsing only for documents exceeding 10β15 MB. Fall back to standard parsing for typical API responses.
Explanation: iOS and Android exhibit different parsing characteristics due to CPU architecture, storage I/O speeds, and runtime configurations. Benchmarks show Android parsing times can be ~5Γ slower than iOS for identical payloads.
Fix: Never hardcode timeout expectations. Implement platform-aware loading states and measure performance on target device tiers during QA.
7. Misusing Path Syntax
Explanation: The navigation API expects specific path formats. Dotted paths like $.metadata.version work for simple traversal, but bracket notation for array indices is often unsupported in the base resolver. Wildcard patterns require dedicated methods.
Fix: Validate path strings against the library's specification. Use wildcard resolvers for array iterations and standard dotted paths for object property access.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Payload < 5 MB | Standard JSON.parse | Lower overhead, faster execution for small trees | Minimal JS heap, negligible CPU |
| Payload 10β100 MB, selective access | Native Lazy JsonView | Avoids full heap allocation, deterministic navigation | ~File size native memory, stable JS heap |
| Payload > 100 MB, full transformation | Server-side sharding + SQLite | Mobile runtime cannot sustain bulk mutation | Shifts compute to backend, reduces client memory |
| High-frequency sync, mutable state | Binary format (MessagePack/Protobuf) | Faster decode, smaller wire size, native support | Higher initial migration cost, long-term performance gain |
| Low-RAM device (< 3 GB) | Stream parsing or chunked API | Prevents native buffer OOM, respects system limits | Increased network round-trips, complex state management |
Configuration Template
// fastJsonConfig.ts
import { LazyJsonReader } from './LazyJsonReader';
export const createDocumentReader = async (
localPath: string,
options: { maxConcurrent?: number; timeoutMs?: number } = {}
): Promise<LazyJsonReader> => {
const reader = new LazyJsonReader(localPath);
const timeout = options.timeoutMs ?? 5000;
await Promise.race([
reader.initialize(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Document initialization timeout')), timeout)
)
]);
return reader;
};
// Usage wrapper with automatic cleanup
export const withDocument = async <T>(
path: string,
handler: (reader: LazyJsonReader) => Promise<T>
): Promise<T> => {
const reader = await createDocumentReader(path);
try {
return await handler(reader);
} finally {
reader.cleanup();
}
};
Quick Start Guide
- Install the native module: Add
react-native-fast-json to your project and run pod install (iOS) or sync Gradle (Android). Ensure the New Architecture is enabled for Nitro compatibility.
- Cache the payload locally: Download the JSON file to the device's document directory. Native parsing requires a file path, not an in-memory string.
- Initialize the reader: Call
createDocumentReader with the cached file path. The native buffer is allocated and parsed in the background.
- Navigate selectively: Use
getScalar or resolvePointer to extract required fields. Avoid calling getSubtree on large arrays or root nodes.
- Release resources: Always invoke
cleanup() in a finally block. Verify native memory returns to baseline using Xcode Instruments or Android Profiler.