@rn-org/react-native-thread
v0.8.3
Published
Run JavaScript on real background threads in React Native — no Workers, no Worklets. Uses Hermes on both iOS and Android, each on a dedicated OS-level thread. Built as a New Architecture TurboModule.
Maintainers
Readme
@rn-org/react-native-thread
Run JavaScript on real background threads in React Native — no Workers, no Worklets. Uses Hermes on both iOS and Android, each on a dedicated OS-level thread. Built as a New Architecture TurboModule.
Contents
- Features
- Requirements
- Installation
- Babel plugin (required for Hermes)
- Quick start
- API reference
- Thread globals
- Hermes & Babel plugin
- Constraints
- Contributing
- License
Features
- True background threads — each thread runs its own isolated Hermes runtime on a dedicated OS-level thread; the main React Native runtime is never blocked. Works on both iOS and Android.
- Unlimited threads — create as many threads as you need; each is isolated.
- Shared thread (
runOnJS) — fire-and-forget tasks on a single persistent background thread; no teardown required. - Promise-based
run()—thread.run(fn, params)returns aPromisethat resolves with the value passed toresolve(data)and rejects on uncaught thread errors. Chain.then()/.catch()or useawait. - Parameter injection — pass values from the main thread into the background function as
(args, resolve) => { ... }. Supports primitives, objects, arrays, and functions. resolveas second param — the callback to send a result back is passed directly as the second argument to your task function — no globals needed.- Function params — pass functions (including imported ones) alongside plain data in the params object. The Babel plugin extracts their source at compile time and captures closed-over variables transitively.
- Cross-module function params — functions imported from other files (e.g.
import { compute } from './math') are automatically resolved and inlined by the Babel plugin. - Named threads — give threads friendly names; list or destroy them by name.
- Error handling — thread exceptions are automatically caught and forwarded to the main thread via the rejected
Promise. - Full
consolesupport —console.log/info/warn/error/debugwork inside threads and appear in Logcat / Xcode logs. - Timer support —
setTimeout,setInterval,clearTimeout, andclearIntervalwork inside threads. - Hermes-safe — ships a Babel plugin that extracts function source at compile time so Hermes bytecode never breaks serialisation. Required on both iOS and Android since both platforms use Hermes.
- New Architecture only — built on the TurboModule / Codegen pipeline.
Requirements
| | Minimum | |---|---| | React Native | 0.76+ (New Architecture) | | iOS | 15.1 | | Android | API 24 | | Node | 18+ |
Installation
npm install @rn-org/react-native-thread
# or
yarn add @rn-org/react-native-threadiOS — run pod install:
cd ios && pod installThe hermes-engine pod is declared as a dependency in the library's podspec; no extra configuration needed.
Android — the Hermes native dependency is declared in the library's build.gradle; no extra steps needed.
Babel plugin (required for Hermes)
Hermes compiles your JS to bytecode at build time. That means fn.toString() at runtime returns a placeholder ("function () { [bytecode] }") instead of source code. The library includes a Babel plugin that:
- Extracts the task function (first argument) source at compile time and replaces it with a string literal.
- Detects function-valued properties in the params object (second argument) and inlines their source.
- Captures closed-over variables transitively — if a function references outer
const/let/varbindings (literals or other functions), those are bundled into a self-contained IIFE. - Resolves cross-module imports — functions imported from relative paths (e.g.
import { fn } from './utils') are read from disk and inlined. - Strips TypeScript annotations automatically when the source file is
.tsor.tsx.
Add the plugin to your app's babel.config.js:
// babel.config.js
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: [
'@rn-org/react-native-thread/babel-plugin',
// ... your other plugins
],
};The plugin is a no-op on non-Hermes builds (V8, etc.).
Note: The plugin safely handles cases where
@react-native/babel-presettransformsasyncfunctions before the plugin's visitor fires. It falls back to the original source text via Babel's preserved AST positions.
Quick start
import { runOnJS, createThread, getThreads, destroyThread } from '@rn-org/react-native-thread';
// ── 1. Fire and forget on the shared background thread ──────────────────────
runOnJS((args) => {
console.log('Limit is', args.limit);
}, { limit: 42 });
// ── 2. Create a named, persistent thread ────────────────────────────────────
const thread = createThread('MyThread');
// Send work + params — thread.run() returns a Promise
const result = await thread.run(
(args, resolve) => {
var sum = 0;
for (var i = 0; i < args.limit; i++) sum += i;
resolve({ sum });
},
{ limit: 1_000_000 }
);
console.log(result); // { sum: 499999500000 }
// ── 2b. Pass functions as params ────────────────────────────────────────────
function multiply(a, b) {
return a * b;
}
const product = await thread.run(
(args, resolve) => {
resolve(args.multiply(args.x, args.y));
},
{ x: 6, y: 7, multiply }
);
console.log(product); // 42
// Async function params work too — the Babel plugin transforms async/await
// to generator-based code before sending to the Hermes thread runtime:
const isPrime = async (num) => {
if (num <= 1) return 'Not Prime';
for (let i = 2; i < num; i++) {
if (num % i === 0) return 'Not Prime';
}
return 'Prime';
};
const verdict = await thread.run(
async (args, resolve) => {
const result = await args.isPrime(args.num);
resolve(`${args.num} is ${result}`);
},
{ num: 17, isPrime }
);
console.log(verdict); // '17 is Prime'
// Functions that close over outer variables work too:
const factor = 10;
const scale = (n) => n * factor;
const scaled = await thread.run(
(args, resolve) => {
resolve(args.scale(5)); // 50
},
{ scale }
);
// Imported functions are also supported:
import { checkEvenOdd } from './utils';
const parity = await thread.run(
(args, resolve) => {
resolve(args.checkEvenOdd(args.num));
},
{ num: 42, checkEvenOdd }
);
// Error handling — thread.run() rejects if the thread throws
try {
const data = await thread.run((args, resolve) => {
throw new Error('something went wrong');
});
} catch (err) {
console.warn(err.message); // 'something went wrong'
}
// Or with .then() / .catch()
thread
.run(async (args, resolve) => {
const result = await args.isPrime(args.num);
resolve(`${args.num} is ${result}`);
}, { num: 17, isPrime })
.then((data) => console.log(data))
.catch((err) => console.warn(err.message));
// ── 3. List all running threads ─────────────────────────────────────────────
console.log(getThreads());
// [{ id: 1, name: 'RNOrgThread' }, { id: 2, name: 'MyThread' }]
// ── 4. Clean up ─────────────────────────────────────────────────────────────
thread.destroy(); // by handle
destroyThread('MyThread'); // by name — same effect
destroyThread(2); // by id — same effectAPI reference
runOnJS
function runOnJS(task: ThreadTask, params?: unknown): voidRuns task on the shared persistent background thread (named "RNOrgThread"). The thread is created on first use and lives for the lifetime of the module. Ideal for fire-and-forget work where you don't need the overhead of managing a dedicated thread.
| Parameter | Type | Description |
|---|---|---|
| task | ThreadTask | Function or code string to execute. |
| params | unknown | Optional JSON-serialisable value passed as the first argument to the function. |
runOnNewJS
function runOnNewJS(task: ThreadTask, params?: unknown, name?: string): ThreadHandleCreates a new isolated thread, immediately runs task on it, and returns a ThreadHandle. The thread persists until you call handle.destroy() or destroyThread(...).
| Parameter | Type | Description |
|---|---|---|
| task | ThreadTask | Function or code string to execute. |
| params | unknown | Optional value passed as the first argument to the function. |
| name | string | Optional display name. Default: RNThread-<id>. |
createThread
function createThread(name?: string): ThreadHandleCreates a new persistent named thread without immediately running any code. Use the returned ThreadHandle to dispatch work at any time.
| Parameter | Type | Description |
|---|---|---|
| name | string | Optional display name. Default: RNThread-<id>. |
getThreads
function getThreads(): ThreadInfo[]Returns a snapshot of every live thread currently managed by the library, including the runOnJS shared thread once it has been started. Threads destroyed via destroyThread or handle.destroy() are removed from this list immediately.
const threads = getThreads();
// [
// { id: 1, name: 'RNOrgThread' },
// { id: 2, name: 'MyThread' },
// ]destroyThread
function destroyThread(idOrName: number | string): voidDestroys a thread and frees its resources. Accepts either the numeric thread ID or the thread's name. When a name is given, the first matching thread is destroyed.
This is the same function called by ThreadHandle.destroy().
destroyThread('MyThread'); // by name
destroyThread(2); // by idIn __DEV__ mode a warning is logged if no matching thread is found.
onMessage
// Callback — fires on every message from any thread
function onMessage(
handler: (data: unknown, threadId: number) => void
): () => void
// Promise — resolves once on the next message from any thread
function onMessage(): Promise<{ data: unknown; threadId: number }>Global listener that fires whenever any thread calls resolveThreadMessage(data). Prefer ThreadHandle.onMessage if you want messages scoped to a single thread.
// Callback variant
const unsub = onMessage((data, threadId) => {
console.log(`Thread ${threadId} sent:`, data);
});
unsub(); // remove listener
// Promise variant — awaits the next message from any thread
const { data, threadId } = await onMessage();
console.log(`Thread ${threadId} sent:`, data);ThreadHandle
Object returned by createThread and runOnNewJS.
type ThreadHandle = {
readonly id: number;
readonly name: string;
/** Runs task on this thread. Resolves with the value passed to resolve(), rejects on error. */
run(task: ThreadTask, params?: unknown): Promise<unknown>;
destroy(): void;
};| Member | Description |
|---|---|
| id | Numeric ID assigned by the native layer. |
| name | Display name provided at creation (or the default RNThread-<id>). |
| run(task, params?) | Execute task on this thread. Returns a Promise that resolves with the value passed to resolve(data) inside the task, or rejects if the thread throws. Can be called multiple times. |
| destroy() | Shut down the thread and remove it from the registry. Equivalent to calling destroyThread(handle.id). |
ThreadInfo
type ThreadInfo = {
readonly id: number;
readonly name: string;
};Returned by getThreads().
ThreadTask
type ThreadTask =
| ((args: any, resolve: (data: unknown) => void) => void)
| string;Either an arrow function / function expression (transformed by the Babel plugin) or a raw JS code string. When a function is used, params is passed as args (first argument) and the resolve callback as resolve (second argument). Call resolve(data) to return a value to the caller.
Thread globals
These globals are available inside every thread function:
resolveThreadMessage
declare function resolveThreadMessage(data: unknown): voidSends data back to the main JS thread. The value is JSON-serialised in the background thread and JSON-parsed before reaching the onMessage handler. Must be JSON-serialisable (object, array, string, number, boolean, or null).
const data = await thread.run((args, resolve) => {
resolve({ status: 'done', value: args.multiply * 2 });
}, { multiply: 21 });
console.log(data); // { status: 'done', value: 42 }
resolveThreadMessageis also available as a global inside the thread (useful for raw code strings), and is the same function thatresolvepoints to.
console
console.log, .info, .warn, .error, and .debug are all available and route to:
- iOS —
NSLog, visible in Xcode / Console.app; tagged[RNThread-<id>] [Hermes]. - Android —
android.util.Log, visible in Logcat; taggedRNThread-<id>.
Timers
setTimeout, clearTimeout, setInterval, and clearInterval are available inside threads.
Both iOS and Android use the same Hermes-based event loop: after the initial evaluation, the thread drains all pending timers (and microtasks) before returning. The thread blocks until all timers have fired or been cleared.
thread.run(() => {
setTimeout(() => {
resolveThreadMessage('delayed hello');
}, 2000);
});Note: The event loop blocks the thread until timers complete. Be careful with
setInterval— the thread won't finish until the interval is cleared inside the thread.
__params__
declare const __params__: anyInjected by the library when you pass a second argument to run(), runOnJS(), or runOnNewJS(). The value is JSON-serialised on the main thread and prepended to the code string as var __params__ = <JSON>;.
You can access the params value in two ways:
// Option A — args/resolve callback parameters
await thread.run(
(args, resolve) => {
for (var i = 0; i < args.iterations; i++) {
// ...
}
resolve('done');
},
{ iterations: 50_000 }
);
// Option B — __params__ global + resolveThreadMessage global
// (works in both functions and raw code strings)
await thread.run(
(args, resolve) => {
for (var i = 0; i < __params__.iterations; i++) {
// ...
}
resolve('done');
},
{ iterations: 50_000 }
);
// Option C — raw code string (__params__ and resolveThreadMessage globals)
thread.run(
'for (var i = 0; i < __params__.iterations; i++) {} resolveThreadMessage("done")',
{ iterations: 50_000 }
);Hermes & Babel plugin deep dive
The problem
The Hermes compiler converts your JS bundle to bytecode at build time. Any function whose .toString() is called at runtime returns something like:
function () { [bytecode] }Because this library must serialise functions to strings and send them to a separate JS engine, .toString() alone doesn't work under Hermes.
The solution
The included Babel plugin runs at compile time — before Hermes touches the code — and transforms both the task function and function-valued params.
Task function (first argument)
Arrow functions and function expressions (including async) are replaced with a string literal wrapped as an IIFE. async/await is transformed to generator-based code because Hermes' eval-mode compiler does not support async syntax. The generated call passes both __params__ and the global resolveThreadMessage so they arrive as args and resolve respectively:
// Input (your source)
thread.run(async (args, resolve) => {
const result = await args.compute(args.num);
resolve(result);
}, { num: 42, compute });
// Output (what Hermes compiles) — async transformed, compute inlined
thread.run("((function(){ ... _asyncToGenerator ... })())(__params__, resolveThreadMessage)", {
num: 42,
compute: { __rnThreadFn: "(function(){ ... })()" }
});Function params (second argument)
Functions passed inside the params object are detected, extracted, and tagged with { __rnThreadFn: "<source>" }. The runtime serializer emits the source directly into the thread code.
// Input
const b = 3;
const add = (x) => x + b;
function multiply(a) {
return add(a) * 2;
}
thread.run(fn, { multiply });
// Output — transitive closures are captured in a self-contained IIFE
thread.run(fn, {
multiply: {
__rnThreadFn: "(function(){ var b = 3;\nvar add = (x) => x + b;\nreturn function multiply(a) { return add(a) * 2; };})()"
}
});Cross-module imports
Functions imported from relative paths are resolved from disk, parsed, and inlined:
// utils.ts
export function checkEvenOdd(num: number): string {
return num % 2 === 0 ? 'Even' : 'Odd';
}
// App.tsx
import { checkEvenOdd } from './utils';
thread.run(fn, { checkEvenOdd });
// → checkEvenOdd is inlined with TypeScript strippedSupported call sites
| Pattern | Task transformed | Params transformed |
|---|---|---|
| runOnJS((args) => { ... }, { fn }) | Yes | Yes |
| runOnNewJS((args, resolve) => { ... }, { fn }) | Yes | Yes |
| anyHandle.run((args, resolve) => { ... }, { fn }) | Yes | Yes |
| Raw code string run("...", { fn }) | No (already a string) | Yes |
What the plugin captures in params
| Value type | Captured |
|---|---|
| Inline arrow / function expression | Yes |
| Inline async arrow / function expression | Yes (transformed to generator) |
| Reference to local function declaration | Yes |
| Reference to local const fn = () => ... | Yes |
| Reference to local const fn = async () => ... | Yes (transformed to generator) |
| Imported function (import { fn } from './mod') | Yes (relative paths only) |
| Closed-over const/let/var with literal init | Yes (transitively) |
| Closed-over function references | Yes (transitively) |
| Runtime-computed values, Map, Set, classes | No — pass as plain params |
Limitations
- Captured closures must be statically resolvable: only variables initialized with literals and functions (local or imported) are captured. Runtime-computed values (e.g.
const x = fetchValue()) must be passed explicitly as params. asyncfunctions in params are supported — the plugin transforms them to generator-based code before extraction.awaitworks inside both task functions and function params.- Cross-module resolution only follows relative imports (e.g.
'./utils'). Package imports (e.g.'lodash') are not resolved. undefined,Map,Set, and class instances are not serialisable as param values — use plain objects, arrays, strings, numbers, booleans, ornull.- Thread functions run in an isolated Hermes runtime with no access to the React tree, native modules, or the main thread's global scope.
Constraints summary
| Constraint | Reason |
|---|---|
| Thread functions run in isolation | They execute in a completely separate Hermes runtime (iOS and Android) |
| Hermes eval-mode has no async/await | The Babel plugin transforms async to generators before extraction |
| Function params must be statically resolvable | The Babel plugin extracts source at compile time |
| Non-function params must be JSON-serialisable | Serialised via JSON.stringify at runtime |
| resolveThreadMessage payload must be JSON-serialisable | Transported as a JSON string over the bridge |
| Cross-module resolution is relative-only | The plugin reads files from disk using the import path |
| No fetch / XMLHttpRequest | Thread runtimes have no network stack |
| New Architecture required | TurboModule / Codegen only; no bridge fallback |
Contributing
License
MIT
