Background thread architecture
A two-runtime pattern — main owns UI, a 'background' runtime owns sync, caching, queues, and parsing.
Use two JavaScript runtimes for app-lifetime background work:
- The main runtime renders UI and handles user interaction.
- A
backgroundruntime stays warm and owns sync, caching, queues, parsing, and other non-visual work.
The recommended setup is native prewarm plus shared Zustand state. Schedule functions with a fixed-runtime directive when a function belongs to only one runtime.
Native prewarm
Start the background runtime from native app startup. This keeps the runtime alive before React screens ask it to do work.
import com.facebook.react.PackageList
import com.nativecompose.threadedruntime.ThreadedRuntime
class MainApplication : Application(), ReactApplication {
override fun onCreate() {
super.onCreate()
ThreadedRuntime.setMainReactPackagesProvider {
PackageList(this).packages
}
loadReactNative(this)
ThreadedRuntime.prewarmBusinessRuntime(applicationContext, "background")
}
}ThreadedRuntime.configure(
withReactNativeDelegate: delegate,
launchOptions: launchOptions
)
ThreadedRuntime.prewarmBusinessRuntime("background")Background runtime entry
Put background-only startup code in a root-level runtime entry file:
index.background.tsThe Metro wrapper discovers files named index.<runtime>.ts in the project
root and emits static conditional requires for them. When native starts a
runtime named background, the generated threaded entry loads
index.background.ts inside that runtime only.
Use this file for module-scope work that must exist before native queues are flushed:
import { registerThreadedHeadlessTask } from '@react-native-runtimes/core';
import { business } from './src/businessStore';
registerThreadedHeadlessTask<{ reason: string }>(
'business:refresh',
async ({ payload }) => {
await business.hydrate();
await business.update(state => ({
...state,
lastRefreshReason: payload.reason,
refreshCount: state.refreshCount + 1,
}));
},
);
void business.hydrate();Keep UI imports out of index.background.ts
Treat the bootstrap file as the background runtime's process: register headless tasks, hydrate stores, start app-lifetime queues, and install background-only listeners. Don't import React components from it.
The file suffix matches the runtime name. If your native prewarm uses a different name, use that same suffix:
ThreadedRuntime.prewarmBusinessRuntime(applicationContext, "sync-engine")index.sync-engine.tsShared Zustand store
Put shared state in @react-native-runtimes/state so main and background
can both read and write the same data.
import { createSharedStore } from '@react-native-runtimes/state';
type BusinessState = {
business: BusinessSnapshot;
};
type BusinessSnapshot = {
lastRefreshReason: string | null;
refreshCount: number;
};
type BusinessAction = {
type: 'refreshRequested';
reason: string;
};
export const businessStore = createSharedStore<BusinessState, BusinessAction>({
name: 'business',
initialState: {
business: {
lastRefreshReason: null,
refreshCount: 0,
},
},
slices: {
business: (state, action) => {
if (action.type !== 'refreshRequested') {
return state;
}
return {
lastRefreshReason: action.reason,
refreshCount: state.refreshCount + 1,
};
},
},
persist: {
key: 'business-v1',
subtrees: ['business'],
},
});
export const business = businessStore.path<BusinessSnapshot>('business');Read it from UI on the main runtime:
function BusinessStatus() {
const state = business.use();
return <Text>{state.lastRefreshReason ?? 'idle'}</Text>;
}Background functions
For work that should always run on the background runtime, define the
function in module/global scope and make 'background' the first statement:
async function refreshBusinessState(reason: string) {
'background';
await business.hydrate();
await business.update(state => ({
...state,
lastRefreshReason: reason,
refreshCount: state.refreshCount + 1,
}));
return business.get();
}
const state = await refreshBusinessState('manual');Metro rewrites that to a registered runtime function and a local scheduled alias:
export const refreshBusinessState_ = runtimeFunction.withId(
'src/business.refreshBusinessState_',
async function refreshBusinessState(reason: string) {
'background';
// function body
},
);
const refreshBusinessState = call(refreshBusinessState_).on('background');Getting results back to main
Don't schedule functions onto the main runtime — write results to the shared store from the background runtime and let main subscribe:
// Background runtime writes:
await business.set(snapshot, true);
// Main runtime UI re-renders automatically:
function BusinessStatus() {
const state = business.use();
return <Text>{state.lastRefreshReason ?? 'idle'}</Text>;
}For request/response work initiated by main, the return value of an awaited runtime function already comes back to the caller — no extra scheduling step is needed.
When to use this pattern
Use the two-runtime architecture when the background side has app-lifetime work:
- sync engines
- caches and hydration
- queues
- local search indexing
- document parsing
- crypto orchestration through native modules
Avoid sending payloads back and forth
Prefer shared Zustand state for progress and results. Don't pass large payloads through function arguments — pass ids or storage references instead. The serialization cost dominates real work otherwise.
Directive functions must be declared at module scope so Metro can rewrite and register them before calls are made.
Use ThreadedRuntime.runHeadlessTask(...) only for native fire-and-forget
jobs that must be queued before JavaScript has a convenient caller. For
normal JS request/response work, prefer the function directive or
call(fn).on(runtimeName)(...args).