Native modules in workers
A worker can call native modules — C++ (Cxx) modules, platform (Java/Kotlin/Objective-C) TurboModules, and legacy (old-architecture) modules through RN's interop layer — so background work can reach storage, crypto, the filesystem, and more, without hopping back to the JS thread.
Import the library the same way you would in your app and call its normal JS API. A handful of modules that are inherently tied to the UI thread are denylisted.
Two tiers
| Works by default | Cost | |
|---|---|---|
| C++ (Cxx) TurboModules + nested workers | ✅ yes | negligible |
| Platform modules (Java/ObjC TurboModules and legacy modules) | opt-in per worker | a per-worker manager (memory + teardown) |
C++ modules are available in every worker with no setup. Platform modules are opt-in because building a per-worker TurboModule manager costs memory and must be torn down with the worker.
Using a library inside a worker
Import the library the same way you would in your app and call its
high-level API — you don't touch the raw TurboModule. Here a worker uses
react-native-blob-util
(a TurboModule) to do filesystem work off the JS thread:
import ReactNativeBlobUtil from 'react-native-blob-util';
self.onmessage = async () => {
const path = `${ReactNativeBlobUtil.fs.dirs.DocumentDir}/from-worker.txt`;
await ReactNativeBlobUtil.fs.writeFile(path, 'hello from a worker', 'utf8');
const text = await ReactNativeBlobUtil.fs.readFile(path, 'utf8');
await ReactNativeBlobUtil.fs.unlink(path);
self.postMessage({ text });
};
// Opt in to platform modules with { nativeModules: true }.
const worker = new Worker('./workers/blobutil', { nativeModules: true });
worker.onmessage = (e) => console.log(e.data.text); // "hello from a worker"
worker.postMessage('go');
The library's JS wrapper resolves the underlying module through
TurboModuleRegistry / NativeModules exactly as it does in your app —
including things like NativeEventEmitter — so its normal API just works.
Legacy (old-architecture) libraries work the same way. This worker uses
react-native-gzip, a plain
ReactContextBaseJavaModule:
import { deflate, inflate } from 'react-native-gzip';
self.onmessage = async (e) => {
const base64 = await deflate(e.data);
self.postMessage({ base64, roundTrips: (await inflate(base64)) === e.data });
};
Low-level access
If you need the raw module (no wrapper), resolve it with TurboModuleRegistry
or, in an inline worker, the __rnworkersGetModule(name) convenience accessor:
const worker = new Worker(
{
inline: `
self.onmessage = () => {
const mod = globalThis.__rnworkersGetModule('SourceCode');
self.postMessage(mod ? mod.getConstants().scriptURL : null);
};
`,
},
{ nativeModules: true },
);
iOS — no setup
On iOS a per-worker RCTTurboModuleManager resolves any registered
Objective-C module through the process-global registry. Nothing to configure.
Android — register your packages once
Android has no global native-module registry, so the worker needs to know which
ReactPackages to build a delegate from. Register them once when the React context
is ready:
reactHost.addReactInstanceEventListener(object : ReactInstanceEventListener {
override fun onReactContextInitialized(context: ReactContext) {
if (context is ReactApplicationContext) {
WorkerTurboModules.initialize(
context,
PackageList(this@MainApplication).packages, // the packages workers may use
)
}
}
})
RN's core modules (SourceCode, PlatformConstants, DeviceInfo, …) are
added automatically — you don't list them. Without initialize, nativeModules
workers fall back to C++ modules only.
What's not allowed
UI-affine modules are denylisted in workers — UIManager, FabricUIManager,
SurfaceRegistry, AccessibilityInfo and DeviceEventManager touch the view
hierarchy, which only exists on the UI thread. TurboModuleRegistry.get returns
null for them. If you need to run JS on the UI thread to reach those, use a
UIWorker instead.
Denial happens when the worker's module registry is built, so a denied module is never constructed in a worker — not merely hidden from lookups.
Blob support
Blob (and everything built on it — URL.createObjectURL, blob-backed fetch
and XMLHttpRequest, WebSocket sends) works inside workers. Blob storage is
shared with the host, so a blob created in a worker resolves on the host
and vice versa.
Blob is not a worker global, because workers deliberately skip RN's
InitializeCore. Import it directly:
import Blob from 'react-native/Libraries/Blob/Blob';
const blob = new Blob(['hello ', 'from a worker'], { type: 'text/plain' });
On Android, workers are served a purpose-built replacement for RN's BlobModule.
RN's own implementation cannot run in a worker: its initialize() installs a
blob-collector callback onto the host runtime while capturing a JNI
reference, which the host's garbage-collector thread later releases without being
attached to the JVM — aborting the process. The replacement delegates all storage
to the host's real BlobModule and installs a per-worker collector that holds no
JNI reference at all, so it is safe to finalize on any thread.
This requires React Native's useTurboModuleInterop flag, which is on by default
under bridgeless. With it disabled, workers simply have no BlobModule and
Blob is unavailable — use SharedBuffer for
binary data instead.
JSI libraries
Libraries that install JSI bindings directly — rather than exposing TurboModule methods — work in workers too, and install into the worker's own runtime:
import { createMMKV } from 'react-native-mmkv';
const storage = createMMKV({ id: 'worker' });
storage.set('key', 'value');
Both react-native-mmkv
(Nitro-based) and
react-native-mmkv-storage
are covered by the example app's test suite.
This needs no cooperation from the library. The common install idiom is:
val jsContext = context.javaScriptContextHolder
install(jsContext.get(), context.jsCallInvokerHolder)
which asks the React context for "the" JS runtime — an assumption that holds
only while an app has exactly one. Given the host context, a worker would install
its bindings onto the host's global and then fail its own isLoaded() check.
So each worker's module registry is built with a ReactApplicationContext whose
javaScriptContextHolder and jsCallInvokerHolder report that worker's runtime
and CallInvoker. Everything else on the context is delegated to the host, so
modules still see the app's real activity, lifecycle and message queues.
Bindings installed this way run on the worker's JS thread, and the library's native state is usually shared process-wide. A library that assumes it is only ever touched from one JS thread may need its own locking. Storage engines like MMKV are designed for concurrent access; not every library is.
Native events
Modules that emit device events work too — see
Native events (NativeEventEmitter).
How it works (short version)
- C++ modules resolve from the process-global CxxTurboModule map, bound to the worker's CallInvoker.
- iOS platform modules: a generic delegate returns
nil, so the manager resolves any class fromRCTGetModuleClasses(). - Android platform modules: C++ builds worker-bound
CallInvokerHolder/RuntimeExecutorholders and calls into a Kotlin bridge that builds aTurboModuleManagerfrom your packages; the whole worker thread runs under the app classloader so JNI resolves your classes. The denylist is applied to that package list, and worker-safe replacements (such as the blob module) are appended to it. The registry is built per worker, against aReactApplicationContextreporting that worker's runtime — see JSI libraries.
Method calls currently run inline on the worker's JS thread. The manager is invalidated before the worker runtime is destroyed, so there's no leak.
Creating and tearing down workers that use native modules is exercised on every
release: repeated create → call → terminate cycles with both a TurboModule
(react-native-blob-util) and a legacy module (react-native-gzip) run with zero
crashes.