8000 GitHub - ammarahm-ed/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. · GitHub
[go: up one dir, main page]

Skip to content

About

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.

Topics

Resources

Code of conduct

Contributing

Stars

115 stars

Watchers

1 watching

Forks

Latest commit

 

History

68 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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.

License: MIT Platforms React Native New Architecture

Documentation · Quick start · API reference · Examples


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. => 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 import npm 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 RCTTurboModuleManager resolves 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. => { /* 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


About

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.

Topics

Resources

Code of conduct

Contributing

Stars

115 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages

0