Real threads for React Native. Web Worker–style API, backed by a separate Hermes runtime on its own thread — with structured-clone messaging, timers, shared memory, and full native-module access.
React Native runs all your JavaScript on a single JS thread, and drives UI updates from it. A long parse, a big image filter, or a tight simulation loop blocks that thread — so it can't dispatch updates and your UI stalls.
This library gives you a real one:
import { Worker } from '@ammarahmed/react-native-workers';
const worker = new Worker({
inline: `self.onmessage = (e) => self.postMessage({ doubled: e.data * 2 });`,
});
worker.onmessage = (e) => console.log(e.data); // { doubled: 42 }
worker.postMessage(21);It's a headless React Native: no UI, but the same JavaScript engine, the same timers and Promises, and the same native modules you already use.
| A real thread | Its own Hermes runtime with its own heap and event loop, not a time-slice of yours. |
| Native modules work | TurboModules, legacy modules and Nitro modules all resolve inside a worker — react-native-mmkv, react-native-blob-util and friends work unmodified. |
| Shared memory, not just messages | A ladder from postMessage to lock-free shared cells and raw shared ArrayBuffers — pick the cost you want to pay. |
| Typed two-way RPC | Call across the boundary like a local async function, in both directions, with one shared type. |
| Small bundles | Worker bundles ship ~150 KB instead of ~1.4 MB, because the UI half of React Native never enters the graph. |
| Debuggable | Every worker is its own DevTools target — breakpoints, stepping, and console.log forwarded to the host with a [Worker:name] tag. |
npm install @ammarahmed/react-native-workersRequires the New Architecture with Hermes (RN 0.81.4+, developed against
0.86). Native code is autolinked; run pod install on iOS.
Then wrap your Metro config — this is what keeps worker bundles small:
const { getDefaultConfig } = require('@react-native/metro-config');
const { withWorkers } = require('@ammarahmed/react-native-workers/metro');
module.exports = withWorkers(getDefaultConfig(__dirname));Full setup — including the Babel plugin for file-based workers and the one-time Android native-module registration — is in the installation guide.
Workers from files — auto-bundled, one Metro graph each
Add the Babel plugin to babel.config.js, then reference a file relatively:
const worker = new Worker('./workers/heavy-task');Each entry is compiled into its own bundle — fetched from Metro in development,
loaded as a Hermes bytecode asset in release. See the
bundling guide
and design-docs/release-bundling.md.
Native modules inside a worker
C++ (Cxx) modules and nested workers work by default. Platform (Java/ObjC) modules are opt-in per worker, because building the per-worker TurboModule manager costs memory:
const worker = new Worker('./workers/task', { nativeModules: true });-
iOS — a per-worker
RCTTurboModuleManagerresolves any registered ObjC module through the global registry. No app changes. -
Android — register your packages once, since there is no global registry:
reactHost.addReactInstanceEventListener(object : ReactInstanceEventListener { override fun onReactContextInitialized(context: ReactContext) { if (context is ReactApplicationContext) { WorkerTurboModules.initialize(context, PackageList(this@MainApplication).packages) } } })
RN's core modules (SourceCode, PlatformConstants, DeviceInfo, …) are added
automatically. UI-affine modules (UIManager and friends) are denylisted.
Details.
Native events — NativeEventEmitter in a worker
// inside a worker
const emitter = new NativeEventEmitter(someNativeModule);
const sub = emitter.addListener('SomeEvent', (payload) => {
// fires on the worker thread
});C++ module events are delivered straight to the worker via its own CallInvoker.
Events from Java/ObjC modules — and anything sent through the app's
DeviceEventEmitter — are emitted on the host and forwarded to workers that
registered a listener.
Details.
Shared state — SharedStore, SharedValue, SharedBuffer
SharedStore is a C++-backed store shared by the host and every worker, with
synchronized reads/writes and change watchers:
const store = new SharedStore('session');
store.set('user', { id: 1, name: 'Ada' });
const off = store.subscribe('user', (key, value) => { /* any runtime wrote */ });Unlike postMessage, a stored value is an addressable tree — read and update
parts of it, and watch just a subtree:
store.setIn('doc', ['user', 'age'], 31); // encodes only the new leaf
store.subscribeIn('doc', ['user'], (path, value) => { /* ['age'], 31 */ });For hot paths there are two leaner primitives:
// A single synchronous cell — lock-free for numbers (~17M writes/sec).
const progress = new SharedValue('anim:progress', 0);
progress.value = 0.42;
// Raw shared memory — same bytes in every runtime, zero host calls per element.
const buf = new SharedBuffer('sim', 50_000 * Float64Array.BYTES_PER_ELEMENT);
const view = new Float64Array(buf.arrayBuffer);SharedValue is ~2× faster than SharedStore for a single value; SharedBuffer
is ~4× faster for bulk numeric work. The full ladder, with benchmarks, is in
design-docs/shared-data-primitives.md.
Typed RPC — the JSModule bridge and defineModule
Call across the worker boundary as if it were a local async function — a
two-way JS→JS RPC:
// worker (JSModule is a global in every worker)
new JSModule('calc', {
add(a, b) { return a + b; },
async fetchUser(id) { return await load(id); },
});// host — fully typed
const calc = worker.module<Calc>('calc');
await worker.ready('calc');
const sum = await calc.add(2, 3); // 5The worker can call back into the host, modules emit events, and large params
are best passed by SharedStore reference rather than cloned per call.
defineModule folds the bridge and reactive state into one contract both sides
implement halves of:
export const calc = defineModule<{
worker: { add(a: number, b: number): number };
host: { getConfig(): Config };
events: { progress: { percent: number } };
state: { status: 'idle' | 'busy' };
}>('calc');Design and wire protocol: design-docs/jsmodule-bridge.md,
design-docs/jsmodule-defineModule.md.
UIWorker — a worker on the UI thread
A non-spec worker whose runtime lives on the platform main thread, so it can call
UI-affine native methods with no thread hop. Same API as Worker; supported on
both platforms. Use sparingly — work here competes with rendering.
const w = new UIWorker({ inline: `self.onmessage = (e) => { /* UI-thread JS */ };` });From native code
Workers can be created and driven from C++ on any thread via
cpp/api/NativeWorkers.h (a JSON message bridge) —
useful for background JS triggered by native events.
Each worker is its own Metro graph, so import … from 'react-native' pulls the
whole framework — renderer, components, Animated, virtualized-lists — into every
worker that reaches it. Third-party native modules import that barrel themselves,
so careful worker code alone cannot avoid it.
withWorkers() resolves react-native to a worker-sized shim inside worker
graphs only. Measured on the example app (Hermes bytecode, minified):
| worker | without | with |
|---|---|---|
react-native-gzip (legacy module) |
1.39 MB | 149 KB |
react-native-mmkv (Nitro) |
1.42 MB | 302 KB |
react-native-blob-util (TurboModule) |
1.50 MB | 264 KB |
| a worker importing nothing | 17.8 KB | 17.8 KB |
Exports a worker cannot use (View, StyleSheet, Animated, hooks…) throw on
access with a message naming the export, instead of being silently undefined.
Structured clone (objects, arrays, cycles, typed arrays, ArrayBuffer, Date),
self / postMessage / onmessage / close / addEventListener, timers,
Promises, structuredClone, error propagation to onerror, terminate, worker
isolation, and NativeEventEmitter.
Also inside a worker: Expo Modules via requireNativeModule(...) on both
platforms, and the experimental
Thread
API for running a worker's own runtime on another thread.
Map / Set / RegExp / Error / BigInt are not in the clone subset yet.
Full transfer-list detach and SharedArrayBuffer are out of scope (Hermes
limitations) — SharedBuffer covers the shared-memory use case
instead.
The example/ app is a gallery of working screens — search service,
image filters, downloads, sensors, parallel parsing, notes — plus a conformance
suite covering the API surface. Step-by-step build-ups for each screen are in the
tutorials.
MIT © Ammar Ahmed