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:
mainrenders UI and handles user interaction.backgroundstays 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")
}
}import NativeComposeThreadedRuntime
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 => ({
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 => ({
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');Main runtime functions
Use 'main' for functions that should run on the main runtime. This is
useful when background work needs to schedule a small UI-owned state update
back to the main runtime:
async function markRefreshVisible(reason: string) {
'main';
await business.update(state => ({
lastRefreshReason: reason,
refreshCount: state.refreshCount + 1,
}));
}
await markRefreshVisible('background-sync-complete');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.
Functions that use 'background' or 'main' 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).