React Native Workers
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.
Why it's different
| 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. |
Workers vs. Worklets
These get compared a lot, so briefly — they solve different problems and are not competitors:
- The unit differs. A worklet is a function plus its captured closure, moved
into another runtime. A worker is a file with its own Metro bundle graph, so you
importnpm packages normally and there are no closure-capture rules to learn. - Both give you real JS runtimes. Worklet runtimes have event loops and timers too, and Bundle Mode lets you import libraries into them. That part isn't a difference.
- The API differs. Workers speak the Web spec —
new Worker(),postMessage,onmessage. Worklets are a lower-level imperative primitive for scheduling work onto a runtime, which is more flexible and less familiar. - For animations and gestures, use Reanimated / Worklets. Their synchronous UI runtime is purpose-built and battle-tested for driving UI at 60–120fps. A message-passing worker shouldn't be your animation layer.
- For "get this work off my JS thread", workers fit. Parsing, crypto, image work, database access, network I/O — with per-worker native module instances and events that stay on the worker.
The long version, including where Worklets is ahead and corrections from their team, is in the comparison.
Install
npm install @ammarahmed/react-native-workers
Requires 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.
A tour
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
});
Events from the worker's own modules — C++, Java or ObjC — are delivered
straight to that worker and never touch the app's runtime. Events raised by the
host (the app's own DeviceEventEmitter, or a module the host owns) are
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); // 5
The 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.
Bundle size
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.
What's supported
Structured clone (objects, arrays, cycles, typed arrays, ArrayBuffer, Date,
Map, Set, RegExp, Error, BigInt), self / postMessage / onmessage /
close / addEventListener, timers, Promises, structuredClone, error
propagation to onerror, terminate, worker isolation, and NativeEventEmitter.
Binary data transfers instead of copying: postMessage(v, [buf]) moves the
backing store, and createTransferableBuffer(n) gives you a buffer that's
zero-copy on every hop.
Native modules inside a worker are isolated — their events are delivered to that worker rather than being raised on the app's runtime and copied back, their method bodies run off the worker's JS thread (Android), and peer lookups resolve to the worker's own instances. A worker's network I/O doesn't depend on the RN JS thread being free.
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.
Limits worth knowing. Hermes has no ArrayBuffer detach, so a transferred
buffer isn't neutered by the engine — this library refuses to reuse it and
.detached reports true, but raw reads still succeed unless you call
enableTransferGuard(). SharedArrayBuffer doesn't exist in Hermes;
SharedBuffer is the shared-memory path. Native-module isolation leans
on private React Native internals, all of which are listed — with what would let me
delete them — in
Hacks & compatibility seams.
Example app
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.
Contributing
License
MIT © Ammar Ahmed