I Replaced Arrays of Objects with One ArrayBuffer - React Native Became 200x Faster
Beyond JSI Call Overhead: Engineering Zero-Copy Data Pipelines in React Native
Current Situation Analysis
The React Native ecosystem widely assumes that adopting JavaScript Interface (JSI) eliminates the performance penalties of the legacy bridge. While JSI successfully removes the asynchronous message-passing layer, it introduces a new, often misunderstood bottleneck: data materialization cost.
Most performance benchmarks isolate the native function invocation time. They measure how fast a C++ method executes, but completely ignore the expense of crossing the language boundary. In production applications, the true cost isn't the native call itself. It's the shape and volume of the payload being reconstructed in the JavaScript heap.
When a native module returns an array of thousands of objects, the JavaScript engine must perform a cascade of hidden operations for every single row:
- Allocate memory for each object instance
- Construct property storage and descriptor arrays
- Box primitive values into JavaScript value types
- Maintain hidden classes (object shapes) for inline caching
- Register allocations in the garbage collector's tracking graph
- Resolve property access paths at runtime
JSI removes the bridge queue. It does not remove the JavaScript engine's object construction pipeline. As dataset sizes grow, the cumulative overhead of materializing row-based objects quickly dominates total execution time, creating unpredictable frame drops and memory pressure in data-heavy applications.
This problem is frequently overlooked because developers profile native execution in isolation, or rely on synthetic benchmarks that use tiny payloads. Real-world workloads like frame processors, sensor streams, and database result sets expose the materialization tax immediately. The solution requires shifting focus from call optimization to data layout optimization.
WOW Moment: Key Findings
The performance delta between row-based object construction and contiguous binary transport is not linear. It scales exponentially with payload size. The following benchmark was captured on an iPhone 16 Pro running Hermes in release mode. Each test executed 10,000 iterations across five columns: id (int32), status (uint8), isActive (uint8), createdAt (double), and updatedAt (double).
| Rows | Row-Based Objects | Columnar ArrayBuffer | Speedup |
|---|---|---|---|
| 100 | ~418.81 ms | ~14.96 ms | 27× |
| 500 | ~2079.81 ms | ~22.06 ms | 94× |
| 1000 | ~4360.11 ms | ~35.89 ms | 121× |
| 2000 | ~9444.47 ms | ~45.39 ms | 208× |
The critical insight isn't the raw speedup. It's the divergence in scaling behavior. The object-based approach pays a compounding tax: every additional row triggers fresh allocations, property table construction, and GC registration. The binary columnar approach performs sequential memory writes into a pre-allocated block. The overhead remains nearly flat regardless of row count.
This finding enables a fundamental architectural shift. Instead of treating the JSI boundary as a serialization problem, you can treat it as a memory mapping problem. By exposing a single contiguous ArrayBuffer and slicing it into typed array views on the JavaScript side, you eliminate parsing, boxing, and object graph traversal. The JavaScript engine interacts with predictable, cache-friendly memory regions, which aligns with how modern CPUs and V8/Hermes optimize typed arrays.
Core Solution
The architecture replaces row-centric object graphs with a columnar binary layout. Data is stored sequentially by field rather than by record. This mirrors the internal design of high-performance analytical systems like Apache Arrow, DuckDB, and ClickHouse, but adapted for the React Native JSI boundary.
Step 1: Define a Strict Binary Schema
Both C++ and JavaScript must agree on field order, data types, and memory alignment. The schema acts as a contract that eliminates runtime parsing.
// types.ts
export type ColumnType = 'int32' | 'uint8' | 'double';
export interface FieldDefinition {
name: string;
type: ColumnType;
byteSize: number;
}
export interface BinarySchema {
fields: FieldDefinition[];
rowStride: number;
headerSize: number;
}
Step 2: Allocate and Write in C++
The native side calculates the total buffer size, allocates a single ArrayBuffer, and writes data column-by-column using typed pointers. This avoids per-row object construction entirely.
// ColumnarWriter.h
#include <jsi/jsi.h>
#include <vector>
#include <cstdint>
namespace columnar {
struct LayoutConfig {
std::vector<FieldDefinition> fields;
size_t row_count;
size_t total_bytes;
};
class LayoutWriter {
public:
explicit LayoutWriter(const LayoutConfig& config)
: config_(config), buffer_(config_.total_bytes) {}
template <typename T>
void write_column(size_t field_index, const std::vector<T>& values) {
size_t offset = config_.header_size + (field_index * config_.row_count * sizeof(T));
std::memcpy(buffer_.data() + offset, values.data(), values.size() * sizeof(T));
}
jsi::Value to_jsi_buffer(jsi::Runtime& rt) {
auto shared_buf = std::make_shared<jsi::Buffer>(std::move(buffer_));
return jsi::ArrayBuffer(rt, std::move(shared_buf));
}
private:
LayoutConfig config_;
std::vector<uint8_t> buffer_;
};
} // namespace columnar
Step 3: Slice and Read in JavaScript
The JavaScript side receives the raw buffer and creates zero-copy typed array views. Each view points directly into the shared memory region. No data is duplicated.
// BufferReader.ts
import { BinarySchema, ColumnType } from './types';
export function createColumnViews(
buffer: ArrayBuffer,
schema: BinarySchema
): Record<string, ArrayBufferView> {
const views: Record<string, ArrayBufferView> = {};
const dataView = new DataView(buffer);
for (let i = 0; i < schema.fields.length; i++) {
const field = schema.fields[i];
const byteOffset = schema.header_size + (i * schema.row_count * field.byteSize);
const byteLength = schema.row_count * field.byteSize;
switch (field.type) {
case 'int32':
views[field.name] = new Int32Array(buffer, byteOffset, schema.row_count);
break;
case 'uint8':
views[field.name] = new Uint8Array(buffer, byteOffset, schema.row_count);
break;
case 'double':
views[field.name] = new Float64Array(buffer, byteOffset, schema.row_count);
break;
}
}
return views;
}
Architecture Decisions & Rationale
Why columnar instead of row-based? Row-based layouts scatter related values across memory. Accessing a single field across thousands of rows forces the CPU to chase pointers and load fragmented cache lines. Columnar layouts pack identical types contiguously. This maximizes cache line utilization, enables SIMD/vectorization opportunities, and aligns with how JavaScript engines optimize typed array iteration.
Why a single ArrayBuffer?
ArrayBuffer represents a fixed-length raw binary buffer. JavaScript engines treat typed arrays backed by ArrayBuffer as predictable memory regions. They bypass hidden class creation, property descriptor lookups, and value boxing. The engine can apply optimized memory access patterns directly.
Why strict schema synchronization? Runtime parsing or JSON serialization reintroduces the exact overhead we're trying to eliminate. A compile-time schema guarantees alignment, removes conditional branching during reads, and allows the C++ side to pre-calculate memory offsets. The tradeoff is reduced flexibility, but performance-critical paths rarely require dynamic schemas.
Pitfall Guide
1. Schema Drift Between Native and JavaScript
Explanation: If the C++ writer and JS reader disagree on field order, type size, or header length, reads will return corrupted data or trigger out-of-bounds exceptions.
Fix: Centralize schema definitions in a shared TypeScript/JSON configuration that generates both C++ headers and JS types. Use static assertions in C++ (static_assert) to verify sizeof() matches expected byte sizes.
2. Ignoring Memory Alignment and Padding
Explanation: CPUs read memory in aligned chunks. Writing misaligned doubles or ints can cause performance penalties or crashes on certain architectures (especially ARM).
Fix: Pad fields to their natural alignment boundaries. Use alignas() in C++ and calculate explicit padding bytes in the schema. Never assume sizeof(T) equals the actual stride without verification.
3. Endianness Mismatches on Cross-Platform Builds
Explanation: iOS (ARM) and Android (x86/ARM) may use different byte orders. Raw binary buffers transferred without endianness normalization will produce reversed values on the target platform.
Fix: Standardize on little-endian (x86/ARM default). Use std::byteswap or htonl/ntohl equivalents when writing multi-byte types. Document the endianness contract explicitly in the schema.
4. Applying Binary Layouts to Small or Sparse Datasets
Explanation: The overhead of schema setup, buffer allocation, and view slicing outweighs benefits for payloads under ~50 rows or datasets with high null/undefined ratios. Fix: Implement a threshold check. Fall back to standard JSI object returns or JSON for small payloads. Reserve columnar buffers for streaming, batch processing, or frame-level data.
5. Debugging Raw Memory Without Visualization
Explanation: Inspecting a raw ArrayBuffer in React Native DevTools yields unreadable hex dumps. Tracking down corruption becomes time-consuming.
Fix: Build a lightweight schema-aware inspector that maps byte offsets to field names. Log typed array slices during development. Use console.table() on the sliced views to verify data integrity before production deployment.
6. Forgetting ArrayBuffer Lifecycle Management
Explanation: JavaScript's garbage collector may reclaim the ArrayBuffer if no strong references exist, causing native-side crashes or JS-side TypeError on subsequent reads.
Fix: Maintain a persistent reference to the buffer in the JS module or component state until all typed array views are consumed. Explicitly nullify references after processing to allow GC to reclaim memory predictably.
7. Mixing Nullable Fields Without Sentinel Values
Explanation: Binary layouts lack native null or undefined representations. Attempting to store optional values without a convention breaks type safety.
Fix: Reserve sentinel values (e.g., 0x7FFFFFFF for int32, NaN for doubles) to represent missing data. Document the convention in the schema and add validation layers in JS to map sentinels back to null.
Production Bundle
Action Checklist
- Define a strict binary schema with explicit byte sizes and alignment rules
- Calculate total buffer size:
(header_bytes) + Σ(field_byte_size × row_count) - Implement C++ writer using typed pointers and sequential column writes
- Expose the buffer via JSI as a single
ArrayBufferinstance - Create typed array views in JavaScript using byte offsets from the schema
- Add schema validation guards to catch drift between native and JS
- Profile memory allocation patterns using Hermes sampling tools
- Implement fallback logic for payloads below the performance threshold
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| < 50 rows, mixed types | Row-based JSI objects | Schema overhead exceeds materialization cost | Low |
| Real-time sensor streams (1000+ rows/sec) | Columnar ArrayBuffer | Eliminates per-frame GC pressure, maintains 60fps | High performance gain |
| Cross-platform data sync | JSON serialization | Handles dynamic schemas, nulls, and strings natively | Moderate CPU/network cost |
| Analytics/Chart rendering | Columnar ArrayBuffer + Web Workers | Enables SIMD processing and off-main-thread computation | High throughput, low latency |
| Database result sets (SQLite) | Columnar ArrayBuffer | Matches internal storage layout, zero-copy extraction | Dramatic reduction in bridge overhead |
Configuration Template
// schema.config.ts
import { BinarySchema } from './types';
export const TELEMETRY_SCHEMA: BinarySchema = {
fields: [
{ name: 'deviceId', type: 'int32', byteSize: 4 },
{ name: 'signalStrength', type: 'uint8', byteSize: 1 },
{ name: 'isOnline', type: 'uint8', byteSize: 1 },
{ name: 'timestamp', type: 'double', byteSize: 8 },
{ name: 'latency', type: 'double', byteSize: 8 }
],
header_size: 16, // Reserved for versioning and row count metadata
row_stride: 0 // Calculated dynamically per column
};
export function calculateBufferSize(schema: BinarySchema, rowCount: number): number {
let total = schema.header_size;
for (const field of schema.fields) {
total += field.byteSize * rowCount;
}
return total;
}
// NativeModule.cpp
#include <jsi/jsi.h>
#include "ColumnarWriter.h"
#include "schema.h"
using namespace facebook::jsi;
Value JSI_EXPORT fetchTelemetryBuffer(Runtime& rt, const Value& args) {
uint32_t rowCount = args.asNumber();
LayoutConfig config;
config.row_count = rowCount;
config.total_bytes = calculateBufferSize(TELEMETRY_SCHEMA, rowCount);
config.header_size = TELEMETRY_SCHEMA.header_size;
columnar::LayoutWriter writer(config);
// Simulate data population
std::vector<int32_t> deviceIds(rowCount);
std::vector<uint8_t> signals(rowCount);
std::vector<uint8_t> onlineFlags(rowCount);
std::vector<double> timestamps(rowCount);
std::vector<double> latencies(rowCount);
// Fill vectors from native source...
writer.write_column(0, deviceIds);
writer.write_column(1, signals);
writer.write_column(2, onlineFlags);
writer.write_column(3, timestamps);
writer.write_column(4, latencies);
return writer.to_jsi_buffer(rt);
}
Quick Start Guide
- Install and Link: Add the columnar utility package to your React Native project. Ensure your C++ toolchain supports C++17 and Hermes JSI headers are accessible.
- Define Schema: Create a TypeScript schema file matching your native data structure. Verify byte sizes and alignment using
sizeof()in C++. - Implement Writer: Replace your existing JSI object-returning methods with a
LayoutWriterinstance. Populate typed vectors and callwrite_column()sequentially. - Consume in JS: Call the native method, receive the
ArrayBuffer, and pass it tocreateColumnViews(). Access data via typed array indices (views.timestamp[0]). - Validate and Profile: Run a memory profiler to confirm zero new object allocations during data transfer. Compare frame times against your previous row-based implementation.
This architecture shifts the performance bottleneck from JavaScript heap management to CPU cache efficiency. When applied to data-intensive React Native modules, it transforms unpredictable materialization costs into deterministic, linear memory operations.
