Bundling file workers
Inline workers need no build setup. File workers (new Worker('./task')) do —
each worker is a separate program that must be compiled into its own bundle. This
page explains the Babel plugin and how loading differs in dev vs release.
The Babel plugin
Add it to your app's Babel config:
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: [require.resolve('@ammarahmed/react-native-workers/plugin')],
};
The plugin scans for new Worker('./relative/path') calls with a string
literal relative path, and:
- records each referenced worker file in a manifest;
- rewrites the call so the runtime knows how to load that worker's bundle.
// you write:
const w = new Worker('./workers/heavy');
// the plugin rewrites it to a descriptor the runtime resolves at load time.
Only relative string literals are rewritten. new Worker({ inline }) and
new Worker(someVariable) are left alone.
Development
In dev, the runtime loads a worker by fetching its bundle from Metro over HTTP and running it inline. This means:
- fast refresh of worker code works like the rest of your app;
- no extra build step during development.
Just run your app normally (react-native start + run the app).
Release builds
For release, there's no Metro server, so each worker bundle must be built ahead of time and shipped with the app.
Expo
Managed Expo apps have no build.gradle or Xcode project to edit, so the config
plugin does both for you. Turn it on with releaseBundling:
{
"expo": {
"plugins": [
["@ammarahmed/react-native-workers", { "releaseBundling": true }]
]
}
}
Then npx expo prebuild and build as usual. The plugin adds the Gradle apply and
the Xcode build phase described below; nothing else is needed.
This only affects release builds. Development still fetches worker bundles from Metro, so you will not see any difference until you build for release.
Bare React Native
Wire the bundling step into your native build:
-
Android — apply the Gradle script from
android/app/build.gradle, after thereact { }block. Let node resolve the package rather than hard-coding a path, so it keeps working whennode_modulesis hoisted:android/app/build.gradleapply from: new File(["node", "--print", "require.resolve('@ammarahmed/react-native-workers/package.json')"].execute(null, rootDir).text.trim(),"../android/worker-bundles.gradle")This registers a
bundle<Variant>WorkerJstask for each release variant and wires its output into the variant's assets. Debug variants are skipped. -
iOS — add a "Run Script" build phase after the stock Bundle React Native code and images phase (that phase creates the resources directory this one writes into):
Xcode ▸ Build Phases ▸ [RN Workers] Bundle worker codeset -eWITH_ENVIRONMENT="$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh"WORKERS_SCRIPT="$("$NODE_BINARY" --print \"require('path').dirname(require.resolve('@ammarahmed/react-native-workers/package.json'))")/scripts/ios-bundle-workers.sh"/bin/sh -c "\"$WITH_ENVIRONMENT\" \"$WORKERS_SCRIPT\""It mirrors
react-native-xcode.sh's skip logic, so it is a no-op for Debug on the Simulator, and picks uphermescfrom thehermes-enginepod.
The CLI that produces the per-worker bundles is also runnable directly:
npx rn-workers-bundle --platform ios --out <dir> # or --platform android
In a workspace that resolves @ammarahmed/react-native-workers through Metro
aliases rather than node_modules, require.resolve will fail. Set
RN_WORKERS_PACKAGE_ROOT to the package directory and the iOS script will use it
instead; on Android, apply from: a direct relative path.
How a bundled worker loads
Each bundle is Hermes-compiled to bytecode when the build uses Hermes, exactly
like your app's own main.jsbundle — so workers start without a parse step.
End to end, new Worker('./workers/parse') in a release build resolves like this:
- The babel plugin has already rewritten the call to reference the worker's
entry id — its path relative to the project root, e.g.
src/workers/parse. - At runtime the id is turned into an asset name by replacing every
non-alphanumeric run with
_:workers/src_workers_parse.jsbundle. The release CLI names its output with the same rule, which is what makes the two sides meet. - The C++ core asks the platform asset reader for those bytes —
NSBundleon iOS, the APK'sAssetManageron Android. There is no app-side setup for this; the library registers its own reader (on Android via a content provider that captures the application context at process start). - The bytes are handed to the worker's Hermes runtime as-is. Nothing branches
on the format: Hermes sniffs the bytecode magic in
evaluateJavaScriptand takes the HBC path, or parses it as JS if it isn't bytecode. That is why the files keep the.jsbundlename whether or nothermescran, and why the reader deals in raw bytes rather than a string.
Because the format is detected rather than declared, a worker that silently shipped as plain JS still runs — it just loses the bytecode startup win. The checks below are how you tell the two apart.
Checking a release build
The bundling step reports what it produced, and ends with a bytecode tally:
rn-workers-bundle: hermes-compiled src/workers/parse (bytecode, 24013 bytes)
rn-workers-bundle: 15/15 worker bundle(s) are Hermes bytecode.
If you see WARNING hermesc not located, the workers shipped as plain JS while
your app bundle is bytecode — pass --hermes <path> to the CLI.
At runtime each bundled worker logs how it loaded, under the RNWorkerAsset tag
(adb logcat -s RNWorkerAsset on Android, the device console on iOS):
[RNWorkerAsset] loaded 'workers/src_workers_parse.jsbundle' (24013 bytes, hermes bytecode) -> worker 3
Tips
- Keep worker files small and dependency-light — every import is bundled into the worker, not shared with your app bundle.
- Prefer inline workers for tiny one-off tasks and file workers for anything you'd want to unit-test or reuse.