Calling a C++ TurboModule from a react-native-worklets Worklet Runtime — so the native code runs off the JS thread.
A minimal, fully working proof-of-concept built on the worklets library's new Bundle Mode. One TurboModule, one method, and an example app that calls it from five different places and prints exactly which JS runtime and which OS thread executed the native code.
The module's only method returns the address of the jsi::Runtime& it was
called with, plus the OS thread it ran on:
| call site | runtime | thread | off the JS thread? |
|---|---|---|---|
| called directly | 0x10c4b1740 (RN) |
29848971 …runtime.JavaScript |
— |
runOnUISync |
0x11b0ec738 (UI) |
29848971 …runtime.JavaScript |
❌ |
runOnRuntimeSync |
0x11c7323b8 (worker) |
29848971 …runtime.JavaScript |
❌ |
runOnUIAsync |
0x11b0ec738 (UI) |
29848845 main thread |
✅ |
runOnRuntimeAsync |
0x11c7323b8 (worker) |
29849264 demo-worker_queue |
✅ |
Two takeaways:
- It works — the C++ TurboModule method genuinely executes on the worklet
runtime's own thread (
demo-worker_queue), with the worklet runtime's ownjsi::Runtime. sync≠ off-thread — the*Syncschedulers enter the worklet runtime on the calling thread. The runtime pointer changes, the thread does not. Only the*Asyncschedulers actually move the work.
The value= counter is a single std::atomic<int> in C++ and increments
1…5 across all five calls — proving every runtime talks to the same
native module instance, not a copy.
TurboModules normally live on the JS thread. Worklet runtimes (the UI runtime,
or workers made with createWorkletRuntime) can't reach them — in Bundle Mode
the registry is explicitly shimmed to refuse (the app demonstrates this live,
last row of the screenshot):
[Worklets] Accessing TurboModules is not allowed on Worklet Runtimes. Requested: "WorkletTurbomodule".
This repo shows the combination that gets around it with zero patches to worklets itself:
-
A C++ TurboModule (
NativeWorkletTurbomoduleCxxSpec). A C++ TurboModule method runs synchronously on the caller's thread and receives the caller'sjsi::Runtime&. An ObjC/Java module would hop to its own module queue — the work would leave the worklet thread again. -
Closure capture instead of registry lookup. The whole library-side trick is src/getRuntimeInfo.native.tsx:
import WorkletTurbomodule from './NativeWorkletTurbomodule'; // registry, RN Runtime, once export function getRuntimeInfo(): string { 'worklet'; return WorkletTurbomodule.getRuntimeInfo(); // captured in the closure }
TurboModuleRegistry.getEnforcingruns once, on the RN Runtime. The worklet captures the resulting object. When the worklet is serialized to another runtime, worklets recognises the TurboModule shape (prototype is a JSI host object) and routes it through its C++createSerializableTurboModuleLike— the host object is shared with the target runtime, not copied.TurboModule::getthen hands out host functions bound to whichever runtime asks. -
Bundle Mode, so worklet runtimes have the full module system (
__r) and worklets are real Metro modules unpacked as__r(workletHash).default(closure)— noeval, real imports inside worklets.
sequenceDiagram
participant RN as RN Runtime (JS thread)
participant W as Worklets serializer
participant WR as Worker Runtime (own thread)
participant TM as C++ TurboModule
RN->>RN: TurboModuleRegistry.getEnforcing('WorkletTurbomodule')
RN->>W: runOnRuntimeAsync(getRuntimeInfo)
Note over W: worklet → {hash, closure}<br/>closure has the TurboModule →<br/>createSerializableTurboModuleLike<br/>(host object shared, not copied)
W->>WR: schedule on worker thread
WR->>WR: __r(hash).default(closure)
WR->>TM: getRuntimeInfo() — synchronous JSI call
TM-->>WR: "runtime=0x11c73… thread=demo-worker_queue"
├── src/
│ ├── NativeWorkletTurbomodule.ts # codegen spec (1 method)
│ └── getRuntimeInfo.native.tsx # the worklet-compatible wrapper (the trick)
├── cpp/
│ └── WorkletTurbomoduleImpl.cpp # C++ TurboModule: reports runtime + thread
├── patches/
│ ├── metro.patch # Bundle Mode: index .worklets/* virtual modules
│ └── metro-runtime.patch # Bundle Mode: Fast Refresh → worklet runtimes
└── example/ # the app from the screenshot
├── babel.config.js # worklets plugin, bundleMode: true
├── metro.config.js # getBundleModeMetroConfig(...)
└── src/App.tsx # calls the module from 5 places
The library is a standard create-react-native-library turbo-module (C++ variant); everything Bundle-Mode-specific is listed above.
As per the setup guide, plus two things worth calling out:
getBundleModeMetroConfig(config)overbundleModeMetroConfig. The function form composes with an existingresolveRequest(here: the monorepo resolver that points the example app at the library source). The plain config object would replace it.- The metro patches are applied via yarn's
patch:protocol in the rootpackage.jsonresolutions— nopatch-package, reproducible from a plainyarn install. They're the upstream worklets patches regenerated against metro 0.83.7.
You can verify Bundle Mode is really on in a served bundle:
curl -s "http://localhost:8081/index.bundle?platform=ios&dev=true" | grep -c "\.worklets/"Verified with React Native 0.83.10, react-native-worklets 0.11.3, Xcode 26,
iOS 26 simulator. The native part is plain C++/pthreads, so the same code path
should apply on Android (not exercised yet).
yarn
yarn prepare # generates ios/generated (codegen) — must run BEFORE pod install
cd example/ios && pod install && cd -
yarn example start --reset-cache
# in another terminal:
yarn example ios- Thread safety is on you. The same C++ module instance is entered from
several threads. This demo uses
std::atomic; a real module needs deliberate synchronization. - Supported machinery, undocumented use case. Everything used here is
existing worklets API surface (
'worklet', closure capture, the TurboModule branch of the serializer) — but no worklets doc promises TurboModule calls on worklet runtimes. Tested against worklets 0.11.3. - Only C++ TurboModules stay on the worklet thread. See "Why this is interesting" above.
MIT
