Memory Ownership Models: When JavaScript Meets Native Code

Authors
Kamil Paradowski
Software Engineer
@
Callstack
Mike Grabowski
CEO & Founder
@
Callstack

You're building a React Native app that processes video frames. Each frame arrives as an ArrayBuffer in JavaScript from a camera feed, a network stream, or a file. You decide to delegate the heavy lifting to native.

You pass your ArrayBuffer to a native module, let it do the work, and get the results back. Simple, right? Well, it depends.

If you keep things synchronous, everything works. But the moment you try to optimize further by offloading work to a background thread, you discover that mixing JavaScript's garbage-collected world with native code's explicit memory management isn't as straightforward as it seems.

In this article, we will take a closer look at those two worlds and what happens under the hood.

The Synchronous Success Story

Let's start with the simple case: synchronous processing. You pass an ArrayBuffer to native code, it processes it immediately, and returns.

const pixels = new ArrayBuffer(1920 * 1080 * 4); // 4K RGBA image
nativeModule.applyFilter(pixels); // Synchronous call
// Done! Buffer is processed

On the native side, you get direct access to the buffer's memory:

// Native side
void applyFilter(jsi::Runtime& runtime, jsi::ArrayBuffer buffer) {
  uint8_t* pixels = buffer.data(runtime); // Raw pointer
  size_t size = buffer.size(runtime);

  // Process the image using optimized native libraries
  processImageFilter(pixels, size);
  // Function returns, buffer is still valid
}

Why is it faster? Native code can use mature processing libraries like OpenCV that operate directly on raw memory and automatically leverage SIMD or hardware acceleration. JavaScript simply doesn’t have equivalent low-level tools today, so native lets you tap into highly optimized routines that already exist.

Asynchronous Processing

But then you think: "What if processing takes longer? I don't want to block the JavaScript thread while native code does heavy work."

So you try to optimize by offloading the work to a background thread:

void processAsync(jsi::Runtime& runtime, jsi::ArrayBuffer buffer) {
  uint8_t* data = buffer.data(runtime);
  size_t length = buffer.size(runtime);

  // Offload to background thread for better performance
  backgroundQueue.async([data, length]() {
    processImage(data, length);
  });
}

This seems reasonable. You capture the raw pointer and length, queue the work, and return immediately so JavaScript isn't blocked. But this is where things break down.

By the time processImage(data, length) actually executes on the background thread, the data pointer may be pointing to nothing, or worse, to memory that's been reused for something else entirely.

Memory Management

To understand what really happened here, we need to look at how each world manages memory:

JavaScript: Automatic with Garbage Collector

Most JavaScript developers live in a world where memory just works:

const buffer = new ArrayBuffer(1024);
// Use it
// Forget about it
// GC cleans it up eventually

You never think about when memory gets freed. You never worry about dangling pointers. And you definitely never coordinate access between threads. There's only one thread anyway.

Under the hood, the JavaScript engine (V8, JavaScriptCore, Hermes) allocates this buffer in its managed heap.

For an ArrayBuffer, the engine creates two parts: an object header and the backing store.

  • The object header is typically ~16-24 bytes and contains metadata like the buffer's type, length, and a pointer to the actual data.
  • The backing store is the actual memory where your 1024 bytes live, which often gets allocated separately for larger buffers.

The engine then tracks this object in its garbage collection system, adding it to the object graph so it knows what's still in use.

When your ArrayBuffer becomes unreachable (no references from any GC roots), the collector eventually kicks in. It marks reachable objects, sweeps away the unmarked ones, and optionally compacts memory to reduce fragmentation (moving objects closer together to eliminate gaps left by freed objects). You don't get to choose when this happens.

But this simplicity comes with trade-offs. GC runs when it wants, not when you want. Modern garbage collectors (including Hades used in Hermes) are mostly concurrent, running in the background without pausing JavaScript execution. You can't say "free this now" or "keep this alive". You're just along for the ride.

Native code: Explicit control

Native code operates under different rules:

void processData(uint8_t* data, size_t length) {
// Who allocated this?
// When will it be freed?
// Can I use it after this function returns?
// What if another thread modifies it?
}

That pointer data is just a 64-bit integer (on 64-bit systems) containing a virtual memory address.

  • It could point to the stack (local variables), the heap (dynamically allocated memory) or a memory-mapped region (memory mapped directly from a file or shared between processes).
  • It might be read-only or writable.
  • The code or thread that allocated it could free it at any moment, invalidating the pointer.
  • And while you're reading this, another thread might be writing to data[100] right now.

Every raw pointer, a direct memory address without any safety wrapper, is a contract. To use memory safely, you need to know who's responsible for freeing it, how long it's valid, whether it can change while you're using it, if multiple threads can access it, whether it's properly aligned, and what the valid address range is. The compiler won't check any of this for you.

Native code gives you control but demands discipline. The compiler won't save you from use-after-free bugs (accessing memory after it's been freed) which can cause undefined behaviour (e.g. corrupt data or crash your program). The runtime won't detect data races. And the CPU? It'll happily load whatever garbage is sitting at that address, or crash with a segmentation fault if the OS no longer has that memory address mapped to real memory.

JSI: smart wrappers around GC-managed objects

JSI sits between these two worlds.

A jsi::Object (and all of its subclasses, such as jsi::ArrayBuffer) is essentially a smart wrapper around a GC-managed value. It keeps the underlying JavaScript object alive and lets the engine update the internal pointer if the object moves during compaction. In practice, that means that as long as you stay inside the JSI API, things "just work" even when the garbage collector is doing its job.

Calling data(runtime) on jsi::ArrayBuffer gives you a raw native pointer into the backing store. That pointer is not a smart wrapper. If the engine decides to move the backing store, JSI will keep the jsi::ArrayBuffer object itself valid, but any raw pointer you previously captured will not be updated.

In Hermes today, ArrayBuffer backing stores are allocated in the C heap using malloc(), so they are not moved by GC compaction. However, this is an implementation detail, not a cross-engine guarantee, and JSI itself cannot promise that ArrayBuffer data is always non-movable. When you design APIs around raw pointers, you should think in terms of the abstract model (the data might move or be detached), not a single engine's current implementation.

The Problems Begin

Let's go back to the previous asynchronous snippet, now that we know a bit about theory of memory management across JavaScript and native.

void processAsync(jsi::Runtime& runtime, jsi::ArrayBuffer buffer) {
  uint8_t* data = buffer.data(runtime);
  size_t length = buffer.size(runtime);

  // Offload to background thread for better performance
  backgroundQueue.async([data, length]() {
    processImage(data, length);
  });
}

Here two things go wrong:

  • The jsi::ArrayBuffer goes out of scope when processAsync returns, so the GC is free to collect it while the background work is still running.
  • You captured a raw pointer and length at the time you scheduled the work. If the backing store ever changed, that pointer would no longer describe the real buffer.

A more disciplined version keeps the jsi::ArrayBuffer alive and only asks for data and size inside the async work:

void processAsync(jsi::Runtime& runtime, jsi::ArrayBuffer buffer) {
  auto sharedBuffer = std::make_shared<jsi::ArrayBuffer>(buffer);

  backgroundQueue.async([sharedBuffer, &runtime]() {
    uint8_t* data = sharedBuffer->data(runtime);
    size_t length = sharedBuffer->size(runtime);

    processImage(data, length);
  });
}

This pattern fixes two important problems:

  • Lifetime: as long as the lambda is alive, sharedBuffer keeps the JavaScript object alive, so the GC will not reclaim it.
  • Pointer freshness: you call data(runtime) and size(runtime) at the point of use, so you always get a pointer that reflects the engine's current backing store.

However, even with this pattern you are still responsible for everything outside JSI:

  • The engine is still allowed (in principle) to detach or reallocate the backing store after you have obtained the pointer.
  • JavaScript code can still mutate the buffer while native code reads it.

What Can You Do?

Given these challenges, what are your options?

Option 1: Stay Synchronous

Keep processing synchronous. This works perfectly, but blocks the JavaScript thread. For quick operations, this is fine. For longer operations, it hurts user experience.

In theory, any JS operation (including ones triggered from native) could cause GC compaction, so the safest pattern is to obtain the pointer as close as possible to where it is actually used.

Option 2: Copy the Data

Copy the buffer to native-owned memory before processing:

void processAsync(jsi::Runtime& runtime, jsi::ArrayBuffer buffer) {
  size_t length = buffer.size(runtime);
  uint8_t* copy = new uint8_t[length];
  memcpy(copy, buffer.data(runtime), length);

  backgroundQueue.async([copy, length]() {
    processImage(copy, length);
    delete[] copy;
  });
}

Copying gives you a simple and reliable boundary: once the data lives in native memory, your async work is entirely decoupled from GC behaviour or JS mutations.

The tradeoff is cost. Moving several megabytes per frame adds up quickly, and for high‑bandwidth workloads it can become the bottleneck.

Option 3: Native-owned buffers

Another approach is to allocate and manage the memory entirely on the native side and expose it to JavaScript as an ArrayBuffer. JavaScript can write into these buffers, but the memory itself stays under native control. That gives you stable, non-movable memory and safe async processing without lifetime surprises.

This is the model used by Nitro Modules, which exposes ArrayBuffers with clear ownership semantics. Nitro lets native code create owning buffers and treat JS-created buffers as non owning, preserving native lifetime guarantees.

func doSomething() -> ArrayBuffer {
  let buffer = ArrayBuffer.allocate(1024 * 10)
  print(buffer.isOwner)    // <-- ✅ true
  let data = buffer.data   // <-- ✅ safe,  we own it!
  self.buffer = buffer     // <-- ✅ safe
  DispatchQueue.global().async {
    let data = buffer.data // <-- ✅ also safe!
  }
  return buffer
}

If you need zero-copy buffers in React Native today, Nitro's approach is the most complete solution available.

Unless JavaScript APIs can write directly into a native-provided ArrayBuffer, there will always be at least one copy. If the data starts in JS, you must copy it into native memory before doing any safe async work. Native-owned buffers avoid all further copies, but they can’t eliminate that first one unless the JS API itself supports writing into externally allocated memory.

Conclusion

Passing objects between JavaScript and native involves two very different memory models. JavaScript relies on a garbage‑collected heap with objects that may move or be detached, while native code works with raw pointers allocated and managed manually. JSI bridges these worlds by keeping JavaScript objects reachable and giving native code controlled access to their backing stores.

Synchronous use is mostly safe because everything happens within a single call, provided you obtain the pointer immediately before using it and avoid triggering additional JS execution during that window.

For asynchronous code, the picture is more nuanced. You can make async processing work by keeping the jsi::ArrayBufferalive and obtaining a fresh pointer at the moment of use, but you still need to account for GC behaviour and any JS execution that may occur before the work runs.

Alternatively, you can:

  • copy into native‑owned memory to avoid these concerns entirely, at the cost of an upfront copy, or
  • use native‑owned buffers (such as those available through Nitro) to get stable memory across async boundaries without repeated copies, as long as the data originates in native.

The one thing you should avoid is capturing a raw pointer and assuming it remains valid across time or threads.

Special thanks to Tzvetan Mikov (@tmikov) for the additional context, explanations, and careful review.

Insights

Learn more about Performance

cover

Jul 3Podcast

Why Nitro Is More Than a Faster Bridge

Marc Rousavy (Margelo) talks Nitro modules, native state, zero-copy data, Vision Camera, and why speed is only part of the story.

cover

May 25Article

Profile React Components in React Native Release Builds

Learn how to profile React components in React Native release builds with @callstack/inspector, using an Expo app setup that requires zero native code changes.

cover

Oct 14Article

From Files to Buffers: Building Real-Time Audio Pipelines in React Native

Learn how to move beyond file-based audio APIs and work directly with in-memory PCM data in React Native. This guide shows how to construct WAV buffers, use JSI for zero-copy transfers, and enable real-time audio processing.

cover

Sep 8Article

How to Add Custom JSI Bindings in Your TurboModule (and Why You Might Need Them)

TurboModules provide a type-safe way to write native code in React Native. JSI sits underneath as a lower-level API, enabling direct interaction with the JavaScript runtime. Beyond what TurboModules expose, JSI gives you access to powerful features, such as working directly with ArrayBuffers. This article shows how to inject a custom binding step by step.

cover

Jul 24Article

Exploring Fibers: Cooperative Multitasking and Lock-Free Task Execution

Explore how fibers enable lightweight, cooperative multitasking. This guide walks through building a simple scheduler, managing task dependencies without locks, and using Tracy to visualize performance.

cover

May 21Article

Multithreading Isn’t Free: Performance Pitfalls Visualized

Learn how to profile and optimize multithreaded apps. Discover why more threads don’t always mean faster results and how bad thread contention really is.

Event Cover

Apr 28Webinar

Master JavaScript Performance in React Native (Part 2)

Learn to fix JS performance issues in React Native-from debounce bugs to Redux selectors, in this hands-on webinar with real code examples.

Events Cover

Apr 24Talk

Tips & Tricks From The Ultimate Guide to React Native Optimization 2025

View flattening, React 19 hooks, real DevTools tips-learn what actually helps your React Native app run faster.

Events Cover

Apr 24Talk

User-Focused Performance Tracking

Discover how Expensify defines and tracks performance metrics that actually impact user experience.

cover

Apr 11Article

Profiling React Native Internals with Tracy for Peak Performance

Learn how to profile React Native internals with Tracy to uncover bottlenecks, optimize performance, and gain deep insights into your app’s native behavior.

cover

Dec 18Podcast

React Native FastIO: Nitro-Powered Performance Boost

Listen to Mike Grabowski talk about React Native FastIO, a collection of primitives for working with data in React Native and its performance benefits.

cover

Oct 15Podcast

Superfast Nitro Modules With Marc Rousavy

Even faster React Native apps? That's possible with Nitro Modules! Tune in to the conversation with their creator, Marc Rousavy.

cover

Mar 19Tutorial

The Hard Way vs. The React Native AI SDK Way: Stop Writing Custom Modules for Every Model

Learn how React Native AI uses a unified provider model to switch between cloud and on-device LLMs without rewriting application logic, enabling reliable AI experiences online and offline.

cover

Mar 12Tutorial

Build Smarter Apps: Tool Calling & AI Orchestration Explained

Learn how on-device models in React Native can call tools, interact with external APIs, and return structured outputs you can use directly in application logic.

cover

Mar 5Tutorial

MLC LLM + React Native: On-Device AI Without the Pain

Learn how to run third-party on-device LLMs in React Native using MLC LLM. Choose your models, run them efficiently across platforms, and keep a unified JavaScript API with React Native AI.

cover

Feb 24Tutorial

Intelligent Fallbacks with Apple Intelligence in React Native

Learn how to use Apple’s built-in Foundation Models in React Native apps, reduce memory usage, and enable fast, fully on-device AI with React Native AI Apple.

cover

Feb 10Tutorial

What Is the React Native AI SDK? A Complete Intro & Quickstart

Learn why on-device AI matters for React Native apps, how local LLMs behave offline, and what problems React Native AI is designed to solve from day one.

cover

Jan 29Tutorial

Implementing an Android TurboModule with Kotlin

Learn how to implement a React Native TurboModule on Android, from the TypeScript spec and Codegen to the Kotlin implementation and package registration.

cover

Jan 19Tutorial

Working with Different Threads in Swift TurboModules

Learn how JavaScript, native module, background, and main threads interact in React Native TurboModules, and how to use GCD queues to run async work safely in Swift.

cover

Jan 9Tutorial

How to Add Type-Safe Constants to Swift TurboModules

Learn how to expose typed, immutable constants from a Swift TurboModule using Codegen, and get full end-to-end type safety between native and JavaScript.

cover

Dec 30Tutorial

Adding Event Emitters to Your TurboModule in Swift

Learn how to emit native events from a Swift TurboModule and subscribe to them in JavaScript, including proper memory management and Codegen integration.

cover

Dec 22Tutorial

Writing Your First TurboModule in Swift

Learn how to build a fully working TurboModule in Swift, integrate it with Codegen, and bridge it through Objective‑C inside an Expo app.