react-native-runtimes, a second JS heap per screen so the feed stops owning the VM
Run heavy React Native components and business logic in isolated Hermes runtimes (without freezing your main JS thread)
At a glance
- What is it?
- Margelo's alpha packages move React Native components, screens, and headless work into named Hermes runtimes, with Metro doing the wiring. Here is what that buys and what it costs.
- Who is it for?
- react-native-runtimes is aimed at an app that has already measured the problem: one or two routes that repeatedly monopolise the main JS thread on a New Architecture build with Hermes. For everyone else, the project's own list of when not to use it is the right advice, because memoisation and virtualisation are cheaper than a second heap.
- Can I use it commercially?
- Not without permission. GitHub finds no licence file in the repository, and without a licence all rights are reserved by default: you may read the code but not reuse it. Check the README, or ask the authors, before using it.
- Is it still maintained?
- Yes. The repository last received commits 58 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 1, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The whole model is one component and a runtime name
The smallest unit of the library is a wrapper component with a string prop. Wrapping a component in `OnRuntime` is enough, and the comment above the first example is blunt about it: the component now renders in its own Hermes instance.
import { OnRuntime } from '@react-native-runtimes/core';
<OnRuntime name="feed-runtime">
<HeavyFeedList userId={userId} />
</OnRuntime>There is no registration step and no provider. Metro rewrites the JSX into a registered threaded boundary at build time, which is why the setup is described as zero boilerplate. The practical consequence is that the boundary lives in your bundler configuration rather than in a provider tree, so renaming a runtime or moving a component between runtimes is a text edit and a rebuild, and nothing appears at the root of your app.
`ThreadedScreen` moves a whole route and names the runtime per conversation
When a navigation flow should live entirely on a secondary runtime, the component is wrapped with `threadedComponent` and rendered through `ThreadedScreen`:
import { ThreadedScreen, threadedComponent } from '@react-native-runtimes/core';
export const ConversationScreen = threadedComponent<Props>(
'ConversationScreen',
(props) => <ConversationRoute {...props} />,
);`ThreadedScreen` takes the component, a props object, and a runtime name. The example builds the name from the conversation id, which is the pattern worth copying: one runtime per conversation means opening a second chat does not have to evict the first from a shared heap. That is also what turns prewarming into a per-row decision rather than a global one, and it is why the library leans on ids rather than on passing the conversation object across the boundary.
`ThreadedRuntime.prewarm` is the answer to a cold first open
Mounting a runtime costs time, so the library lets you start one before the user navigates. The example says when to call it:
import { ThreadedRuntime } from '@react-native-runtimes/core';
// e.g. when the inbox row becomes visible
await ThreadedRuntime.prewarm(`chat-${conversationId}`);Triggering from a row becoming visible turns a route transition into background work. The scenario this addresses is a slow first open on a heavy screen, where the main runtime stays free and navigation latency stays predictable because the heap is already populated. The trade is that a prewarmed runtime holds memory for as long as it is named, so an inbox that scrolls through hundreds of conversations is a memory budget you now have to think about rather than something that happens for free.
Headless tasks run JS with no view attached at all
Not every expensive thing is a screen. A headless task is registered on the threaded bundle side and dispatched from anywhere, with no component involved:
// Register on the threaded bundle side:
registerThreadedHeadlessTask('hydrateConversation', async ({ payload }) => {
const messages = await loadMessages(payload.conversationId, payload.limit);
await messagesStore.setSubtreeState(payload.conversationId, messages, true);
});
// Dispatch from anywhere:
await ThreadedRuntime.runHeadlessTask('hydrateConversation', {
runtimeName: 'chat-worker-runtime',
payload: { conversationId, limit: 50 },
});The documented uses are pre-hydrating stores, decoding data, and running reducers in a long-lived worker. Note the registration is keyed by name and lives on the threaded side, so the worker runtime has to be alive for the task to land, which is the argument for naming it something like a worker runtime rather than reusing a screen runtime. The payload shape is yours, which is both the flexibility and the hazard: nothing stops you putting a megabyte of decoded content on the far side of that call, and the serialisation cost lands exactly where you were trying to move work away from.
Arguments cross the heap as JSON, and that constraint shapes the design
Cross-runtime calls are typed on the surface and serialised underneath. A function is wrapped with `runtimeFunction`, and `call` dispatches it to a named runtime:
export const fibonacci = runtimeFunction((n: number) => ({
input: n,
result: fibonacciNumber(n),
computedAt: new Date().toISOString(),
}));
const result = await call(fibonacci).on('fibonacci-worker-runtime')(38);The documentation states plainly that arguments and return values are JSON-serialised automatically, and the list of reasons not to use the library leans on that: you should not pass large mutable objects or non-serializable props directly between runtimes. The prescribed pattern is to pass ids and keys and read the actual data from native-backed shared state. Once you accept that, the boundary stops being a performance tool and starts being an API design constraint, which is a bigger change than it first appears. The mapping the project gives for symptoms is worth reading as a decision rule rather than a feature list: a chat or inbox that janks on mount means move the expensive list surface, reducers competing with animation means move the business logic, slow first opens mean prewarm, background hydration means a worker, and state that has to be visible everywhere means the native-backed store.
A bare string literal reroutes an entire function
For helpers that always belong on one runtime, there is a shorter form than `call`. The first statement of the function is a bare string literal, and Metro rewrites the call site so the body executes on the runtime that name refers to:
async function refreshCache(key: string) {
'background'; // ← directive: this function always runs on 'background' runtime
await cacheStore.hydrate();
return cacheStore.get(key);
}
const value = await refreshCache('settings'); // cross-runtime, no extra APIThe caller sees an ordinary local call, which is convenient, and the cost is that the hop is invisible at the call site. A function like the one above hydrates a cache and reads a value, so a reader who has not seen the directive will assume both happened on the calling runtime. It is a good pattern for a narrow fixed-runtime helper and a bad one for anything a reader needs to reason about, which is a trade the one-line form makes entirely on the author's behalf.
Shared state is a native singleton, so reads never touch the bridge
The state package is a Zustand-style store held in a process-wide C++ singleton, which is what makes cross-heap reads synchronous. You create it with a name and an initial state:
import { createSharedStore } from '@react-native-runtimes/state';
export const chatStore = createSharedStore({
name: 'chat',
initialState: { messages: {}, settings: { theme: 'dark' } },
});Any runtime can write and every subscriber is notified, and the documentation is explicit that there is no bridge round-trip on a read. That is the piece that makes the rest workable, because it gives the JSON-serialised boundary something to point at: a chat screen on one heap and its background hydration task on another read and write the same store without either side marshalling a snapshot. The two packages are designed as a pair for that reason, and adopting only the core one leaves you with the boundary and none of the state model.
Two alpha packages, a Callstack collaboration, and no licence file
The maturity signals come first. The three most recent tags are `@react-native-runtimes/[email protected]` and `@react-native-runtimes/[email protected]`, both on 2026-08-05, and `@react-native-runtimes/[email protected]` on 2026-06-01. The branch was last pushed on 2026-08-05, and the project is built in collaboration with Callstack, which is a meaningful signal for a New Architecture library. The repository is TypeScript, and the root package is a private workspace that drives everything through `bun --cwd example`, requiring Node 22.11.0 or newer. Two details deserve a look before you depend on it: the tree contains both `bun.lock` and `package-lock.json`, and it does not contain a licence file even though the README links to one, so the terms are not settled by anything in the repository. The `example/` directory is where the real integration surface is, and it is worth reading before installing anything: alongside the usual `android/`, `ios/`, `metro.config.js`, `babel.config.js`, and `App.tsx`, it carries a `.harness/` directory, an `rn-harness.config.mjs`, a separate `jest.harness.config.mjs`, a `maestro/` folder for end-to-end flows, and a second entry point named `index.business-runtime.ts` beside the normal `index.js`.
Editorial conclusion
react-native-runtimes is aimed at an app that has already measured the problem: one or two routes that repeatedly monopolise the main JS thread on a New Architecture build with Hermes. For everyone else, the project's own list of when not to use it is the right advice, because memoisation and virtualisation are cheaper than a second heap. Before adopting it, confirm you can live with JSON-serialised boundaries between runtimes, read the alpha version numbers as the maturity signal they are, and check the licensing position, since the repository tree does not contain a licence file.
Frequently asked questions
What does react-native-runtimes require before it will work?
A React Native app on the New Architecture running Hermes. The project says it is not for legacy architecture or a non-Hermes JS engine, and that you should move there or already be there. Expo apps get a config plugin rather than hand-written runtime plumbing.
Can I pass a large object between react-native-runtimes heaps?
Not directly. Arguments and return values on cross-runtime calls are JSON-serialised automatically, and the project explicitly advises against passing large mutable objects or non-serializable props. Pass ids and keys instead, and read the real data from the native-backed shared store.
How do I start a runtime before the user opens a screen?
Call ThreadedRuntime.prewarm with the runtime name you intend to use later. The documented trigger is a row becoming visible, such as an inbox row, so the heap is already populated by the time the transition happens.
How do react-native-runtimes run background work with no UI?
With headless tasks. You register a named task on the threaded bundle side and dispatch it with ThreadedRuntime.runHeadlessTask, supplying the runtime name and a payload. The documented uses are pre-hydrating stores, decoding data, and running reducers in a worker.
Is react-native-runtimes ready for production use?
Both packages are at 0.1.0-alpha.2 as of 2026-08-05, which is a pre-1.0 signal you should weigh. The project is built in collaboration with Callstack, and the repository tree has no licence file even though the README links to one, so check the terms separately.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/margelo-react-native-runtimes)