---
title: "Memory Ownership Models: When JavaScript Meets Native Code"
description: "A deep dive into how React Native handles memory when JavaScript and native code share ArrayBuffer data. Learn why async processing can break, how GC affects raw pointers, and the safe patterns for building high-performance native integrations."
url: https://www.callstack.com/blog/memory-ownership-models-when-javascript-meets-native-code
updated: 2026-10-24T08:39:33.081Z
---

# Memory Ownership Models: When JavaScript Meets Native Code

A deep dive into how React Native handles memory when JavaScript and native code share ArrayBuffer data. Learn why async processing can break, how GC affects raw pointers, and the safe patterns for building high-performance native integrations.

_By Kamil Paradowski, Software Engineer @ Callstack · Published 2025-11-21 · Category: Performance_

You're building a React Native app that processes video frames. Each frame arrives as an [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/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.

```javascript
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:

```cpp
// 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:

```cpp
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:

```javascript
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.

![](https://www.callstack.com/assets/69172ce36e1f9f3035328009_69172b6f7362038ba5b13a53_896ded41-b7ed-47dd-9085-4661be925051.webp)

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:

```cpp
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.

```cpp
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.

![](https://www.callstack.com/assets/69172d44f02a7883d1360f7c_69172d280e322df2779e1f80_1d2e8e86-caab-4dac-a074-1c6e09d6716a.webp)

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

```cpp
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:

```cpp
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.

```swift
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::ArrayBuffer`alive 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)_](https://x.com/tmikov) _for the additional context, explanations, and careful review._
