Skip to content

About

πŸš€ High-performance utility for zero-copy data transport from JSI C++ to JavaScript via columnar ArrayBuffer layout

Topics

Resources

Code of conduct

Contributing

Stars

26 stars

Watchers

1 watching

Forks

Latest commit

Β 

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

react-native-columnar

react-native-columnar

npm license platform

Zero-copy columnar ArrayBuffer transport from JSI C++ to JavaScript.

JSI modules often return datasets as arrays of objects β€” every row becomes a JS object, every value gets boxed, GC pressure grows. react-native-columnar packs all values into one binary buffer and exposes each column as a typed array view over the same memory. No objects, no parsing, no copy.


⚑ Benchmark

iPhone 16 Pro Β· iOS 26.5 Β· Release build Β· Hermes Β· time per JSI call (mean of 2 runs, each the median of 5 Γ— 1 000 calls)

id (int32) | status (uint8) | isActive (uint8) | createdAt (double) | updatedAt (double)

Every variant returns exactly the same data (checksums are compared). The array-of-objects baseline creates property names once per call, not once per row.

Fetch and read one row β€” the cost of getting data across the bridge:

Rows Array of objects JSON.parse columnar columnar, reused buffer
100 17.8 Β΅s 73.1 Β΅s (0.2Γ—) 1.6 Β΅s (11Γ—) 1.3 Β΅s (13Γ—)
500 81.7 Β΅s 364 Β΅s (0.2Γ—) 2.8 Β΅s (29Γ—) 1.8 Β΅s (47Γ—)
1000 172 Β΅s 727 Β΅s (0.2Γ—) 3.9 Β΅s (44Γ—) 2.3 Β΅s (76Γ—)
2000 355 Β΅s 1,480 Β΅s (0.2Γ—) 6.6 Β΅s (54Γ—) 3.3 Β΅s (109Γ—)

Fetch and read every value of every row β€” transport plus a full scan in JS:

Rows Array of objects JSON.parse columnar columnar, reused buffer
100 20.1 Β΅s 75.0 Β΅s (0.3Γ—) 3.6 Β΅s (6Γ—) 3.2 Β΅s (6Γ—)
500 99.7 Β΅s 379 Β΅s (0.3Γ—) 11.9 Β΅s (8Γ—) 10.9 Β΅s (9Γ—)
1000 201 Β΅s 764 Β΅s (0.3Γ—) 22.1 Β΅s (9Γ—) 20.5 Β΅s (10Γ—)
2000 409 Β΅s 1,556 Β΅s (0.3Γ—) 42.7 Β΅s (10Γ—) 40.1 Β΅s (10Γ—)

"Reused buffer" refills one preallocated ArrayBuffer with ColumnarBufferWriter instead of allocating a new one per call (see Reusing a buffer). The benchmark lives in example/src/App.tsx; launch the Release build with -benchmarkAutorun 1 to run it unattended. Debug builds are not representative: the C++ side is compiled without optimizations there.


🎯 Best use cases

SQLite result sets Β· Frame processor outputs Β· Sensor streams Β· Analytics events Β· Realtime charts Β· Large JSI payloads


πŸ“¦ Installation

npm install react-native-columnar
# or
yarn add react-native-columnar

iOS β€” headers are picked up automatically via CocoaPods.

Android β€” app project

Autolinking registers the package automatically. Add to android/app/build.gradle:

android {
  buildFeatures { prefab true }
}

Then in CMakeLists.txt:

find_package(react-native-columnar REQUIRED CONFIG)
target_link_libraries(${YOUR_LIBRARY_NAME} react-native-columnar::react-native-columnar)

Android β€” standalone library

Add to package.json:

{ "dependencies": { "react-native-columnar": "*" } }

Then in CMakeLists.txt (NODE_MODULES_DIR is already passed by any JSI library):

include_directories(${NODE_MODULES_DIR}/react-native-columnar/cpp)

In your C++ files:

#include "react-native-columnar.h"

πŸ”§ C++ side

1. Define a schema

#include "react-native-columnar.h"

#define USER_COLUMNS(X)       \
  X(int32_t, id)              \
  X(uint8_t, status)          \
  X(uint8_t, isActive)        \
  X(double,  createdAt)       \
  X(double,  updatedAt)

RN_COLUMNAR_DECLARE_SCHEMA(UserSchema, USER_COLUMNS)

Generates UserSchema with columnCount, byteSize(), and a Columns struct of std::span views. (DECLARE_BINARY_SCHEMA still works as an alias.)

Only types from the mapping table are accepted β€” anything else (bool, int64_t, char, long, …) fails at compile time with a static_assert naming the column. Use uint8_t for booleans.

2. Write and return an ArrayBuffer

using namespace rn_columnar;

jsi::Value getUsers(jsi::Runtime& rt, const jsi::Value*, const jsi::Value* args, size_t) {
  const uint32_t rows = static_cast<uint32_t>(args[0].asNumber());

  ColumnarWriter<UserSchema> writer(rows);
  auto& cols = writer.columns();

  for (uint32_t i = 0; i < rows; ++i) {
    cols.id[i]        = dbRow[i].id;
    cols.status[i]    = dbRow[i].status;
    cols.isActive[i]  = dbRow[i].isActive;
    cols.createdAt[i] = dbRow[i].createdAt;
    cols.updatedAt[i] = dbRow[i].updatedAt;
  }

  return std::move(writer).toArrayBuffer(rt); // zero-copy move into JSI
}

toArrayBuffer() must be called on an rvalue (std::move(writer)): ownership of the memory passes to JS, and the explicit move makes that hand-off visible (and lets clang-tidy's bugprone-use-after-move catch later use of the writer).

(ColumnarWriterBuilder is still available as an alias of ColumnarWriter.)

3. Unknown row count (e.g. SQLite)

When the number of rows isn't known up front, start with a guess, grow with resize() while filling, and trim to the real count at the end. Existing rows are kept, new rows are zero-filled:

ColumnarWriter<UserSchema> writer(256);
auto& cols = writer.columns(); // reference stays valid across resize()

uint32_t n = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) {
  if (n == writer.rows()) {
    writer.resize(writer.rows() * 2);
  }
  cols.id[n]        = sqlite3_column_int(stmt, 0);
  cols.createdAt[n] = sqlite3_column_double(stmt, 1);
  // ...
  ++n;
}

writer.resize(n); // trim: compacts columns in place
return std::move(writer).toArrayBuffer(rt);

4. Skipping the zero-fill

By default the buffer is zeroed on allocation. If you write every cell anyway, skip that pass:

ColumnarWriter<UserSchema> writer(rows, Init::Uninitialized);

Unwritten cells (and rows added by resize()) then contain garbage, so use it only when the loop covers every row of every column. It matters for large buffers (hundreds of thousands of rows); for small ones the difference is negligible.

5. Filling on a background thread

ColumnarWriter doesn't touch the JS runtime until toArrayBuffer(), so the heavy part (a DB query, frame processing) can run on any thread. Only toArrayBuffer() and anything involving jsi::Value must run on the JS thread. A Promise-based host function using CallInvoker:

#include <ReactCommon/CallInvoker.h>

jsi::Function makeGetUsersAsync(jsi::Runtime& rt, std::shared_ptr<react::CallInvoker> jsInvoker) {
  return jsi::Function::createFromHostFunction(
      rt, jsi::PropNameID::forAscii(rt, "getUsersAsync"), 0,
      [jsInvoker](jsi::Runtime& rt, const jsi::Value&, const jsi::Value*, size_t) -> jsi::Value {
        auto executor = jsi::Function::createFromHostFunction(
            rt, jsi::PropNameID::forAscii(rt, "executor"), 2,
            [jsInvoker](jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t) -> jsi::Value {
              // jsi::Value may only be touched on the JS thread: the worker just carries these pointers along.
              auto resolve = std::make_shared<jsi::Value>(rt, args[0]);
              auto reject = std::make_shared<jsi::Value>(rt, args[1]);

              std::thread([jsInvoker, resolve = std::move(resolve), reject = std::move(reject)]() mutable {
                // Background thread: no jsi calls here, the writer needs no runtime.
                std::shared_ptr<ColumnarWriter<UserSchema>> writer;
                std::string error;
                try {
                  const auto rows = queryUsers();
                  writer = std::make_shared<ColumnarWriter<UserSchema>>(
                      static_cast<uint32_t>(rows.size()), Init::Uninitialized);
                  auto& cols = writer->columns();
                  for (size_t i = 0; i < rows.size(); ++i) {
                    cols.id[i] = rows[i].id;
                    cols.createdAt[i] = rows[i].createdAt;
                  }
                } catch (const std::exception& e) {
                  error = e.what();
                }

                // Moving resolve/reject into the JS-thread task makes sure they are destroyed there.
                jsInvoker->invokeAsync([writer = std::move(writer), error = std::move(error),
                                        resolve = std::move(resolve), reject = std::move(reject)](jsi::Runtime& rt) {
                  if (writer) {
                    resolve->asObject(rt).asFunction(rt).call(rt, std::move(*writer).toArrayBuffer(rt));
                  } else {
                    auto jsError = rt.global().getPropertyAsFunction(rt, "Error")
                                       .callAsConstructor(rt, jsi::String::createFromUtf8(rt, error));
                    reject->asObject(rt).asFunction(rt).call(rt, jsError);
                  }
                });
              }).detach();
              return jsi::Value::undefined();
            });
        return rt.global().getPropertyAsFunction(rt, "Promise").callAsConstructor(rt, std::move(executor));
      });
}

The jsInvoker comes from your TurboModule (jsInvoker_) or from RCTCxxBridge.jsCallInvoker / CatalystInstance.getJSCallInvokerHolder() in legacy modules. In production, prefer a thread pool or a serial queue over std::thread per call.

6. Reusing a buffer (streams, frames)

For high-frequency data (sensors, frame processors) allocating a new buffer per call creates GC pressure. Instead, allocate once in JS and let native code refill it in place with ColumnarBufferWriter:

// JS calls: __fillFrame(buffer, rows)
jsi::Value fillFrame(jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t) {
  const auto buffer = args[0].asObject(rt).getArrayBuffer(rt);
  const auto rows = static_cast<uint32_t>(args[1].asNumber());

  ColumnarBufferWriter<PointSchema> writer(rt, buffer, rows); // throws if the buffer is too small
  auto& cols = writer.columns();
  for (uint32_t i = 0; i < rows; ++i) {
    cols.x[i] = points[i].x;
    cols.y[i] = points[i].y;
  }
  return jsi::Value::undefined();
}
const { buffer } = createBufferWriter(POINT_SCHEMA, MAX_POINTS); // allocated once

function onFrame(rows: number) {
  __fillFrame(buffer, rows);
  const [, [x, y]] = createBufferReader(buffer, POINT_SCHEMA); // re-read: row count may change
  // ...
}
  • The buffer may be larger than needed: the header stores the actual row count, and ColumnarBufferWriter<S>::capacity(byteLength) tells how many rows fit.
  • Old bytes are not cleared β€” write every cell.
  • Use it on the JS thread only. If JS may still be reading the previous frame (e.g. it is passed to an animation), alternate between two buffers.

🟦 JS side

1. Define the schema

Must match column order and types from C++:

import { createBufferReader, ColumnType } from 'react-native-columnar';

const USER_SCHEMA = [
  ColumnType.Int32,    // id
  ColumnType.Uint8,    // status
  ColumnType.Uint8,    // isActive
  ColumnType.Float64,  // createdAt
  ColumnType.Float64,  // updatedAt
] as const;

2. Read the buffer

const buffer: ArrayBuffer = __getUsers();

const [header, columns] = createBufferReader(buffer, USER_SCHEMA);
const [idCol, statusCol, isActiveCol, createdAtCol, updatedAtCol] = columns;
// idCol β€” Int32Array  |  statusCol β€” Uint8Array  |  createdAtCol β€” Float64Array

const id        = idCol[0];
const status    = statusCol[0];
const isActive  = isActiveCol[0];
const createdAt = createdAtCol[0];
const updatedAt = updatedAtCol[0];

All columns are zero-copy typed array views β€” the buffer is never copied.

API

createBufferReader(buffer: ArrayBuffer, schema: readonly ColumnType[])
  // β†’ [header: Int32Array, columns: TypedArray[]]
  // header[0] = row count, header[1] = column count

createBufferWriter(schema: readonly ColumnType[], rows: number)
  // β†’ { buffer: ArrayBuffer, columns: TypedArray[] }  (writable views, same layout as C++)

getBufferSize(schema: readonly ColumnType[], rows: number)
  // β†’ total ArrayBuffer size in bytes, header included

Mocking native modules in tests

createBufferWriter builds a buffer byte-for-byte identical to what ColumnarWriter produces, so Jest tests can run without native code:

jest.mock('./native', () => ({
  getUsers: () => {
    const { buffer, columns } = createBufferWriter(USER_SCHEMA, 2);
    const [id, status, isActive, createdAt, updatedAt] = columns;
    id.set([1, 2]);
    status.set([0, 1]);
    isActive.set([1, 1]);
    createdAt.set([1710000000000, 1710000000500]);
    updatedAt.set([1710000001000, 1710000001500]);
    return buffer;
  },
}));

ColumnType mapping

ColumnType C++ type JS view Bytes Tip
Int8 int8_t Int8Array 1
Uint8 uint8_t Uint8Array 1 bool, flags
Int16 int16_t Int16Array 2
Uint16 uint16_t Uint16Array 2
Int32 int32_t Int32Array 4 id, count, enum
Uint32 uint32_t Uint32Array 4
Float32 float Float32Array 4 screen coords (~7 sig. digits)
Float64 double Float64Array 8 timestamp, price

πŸ—‚ Memory management

Buffer ownership

ColumnarWriter allocates a std::vector<uint8_t> internally. Calling std::move(writer).toArrayBuffer(rt) moves the vector into a shared_ptr<VectorBuffer> (a jsi::MutableBuffer subclass) and hands it to the JSI runtime. After this call the writer is released: columns(), resize() and a second toArrayBuffer() throw std::logic_error, and the spans held by the writer are reset to empty.

Lifetime on the JS side

The JS runtime (Hermes / V8) becomes the sole owner of the ArrayBuffer. All typed-array views returned by createBufferReader are zero-copy views over the same memory β€” each view holds an implicit reference to the ArrayBuffer.

The underlying std::vector is freed when all JS references are gone: the original ArrayBuffer object and every typed-array view derived from it. No explicit free() or reference counting is required.

ColumnarWriter         β†’  toArrayBuffer()  β†’  shared_ptr<VectorBuffer>
                                                       ↑
                              jsi::ArrayBuffer  β”€β”€β”€β”€β”€β”€β”€β”˜   (JSI runtime owns)
                                    ↑
                Int32Array / Float64Array / …            (views, no copy)

All JS refs dropped  β†’  GC  β†’  shared_ptr ref-count = 0  β†’  vector freed

Practical rules

  • Don't keep a typed-array view alive longer than needed β€” it pins the entire buffer in memory.
  • Take columns by reference (auto& cols = writer.columns()) and don't write to them after toArrayBuffer() β€” the memory now belongs to JS and may already be freed. A copy (auto cols = ...) keeps raw spans that the writer cannot reset.
  • toArrayBuffer() can be called only once per writer.
  • resize() re-lays out the buffer: an auto& reference from columns() stays valid, raw std::span copies and pointers taken before it do not. Growing copies the data (O(size)), so grow geometrically (Γ—2), not row by row.

⚠️ Limitations

Designed for dense numeric data only. Strings, nullable values, nested objects, and variable-length fields are not supported natively β€” encode them as fixed-width columns using ids, offsets, or sentinel values.


πŸ›  Troubleshooting

react-native-columnar.h not found on Android β€” check that prefab true is enabled and CMake links the package correctly.

std::span errors β€” set C++20 on the target that includes the header.

static_assert: column '…' has unsupported type β€” the column uses a C++ type with no JS counterpart. Switch it to one of the types in the mapping table (e.g. bool β†’ uint8_t).

RangeError in JS β€” JS and C++ schemas are out of sync. Check column order and types match exactly (int32_t β†’ Int32, double β†’ Float64).

Values look shifted β€” one wrong type shifts all following columns. Compare schemas line by line.


Contributing

License

MIT

About

πŸš€ High-performance utility for zero-copy data transport from JSI C++ to JavaScript via columnar ArrayBuffer layout

Topics

Resources

Code of conduct

Contributing

Stars

26 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages