Offloading Heavy Tasks in React Native: Multi-Threading with Margelo Runtimes and Shared State
Executive Summary (TL;DR)
React Native executes application logic and UI interactions on a single JavaScript thread, which can cause frame drops during heavy computations like JSON parsing, local search filtering, or cryptography. Using Margelo Runtimes alongside Nitro Modules, developers can spin up lightweight, isolated Hermes runtimes on native background threads to process CPU-heavy workloads asynchronously. This guide walks through configuring native dependencies, dispatching worklet computations, and synchronizing shared reactive state across runtimes without bridge serialization bottlenecks.
The Case for Multi-Threading in React Native
React Native processes application state, UI events, and rendering passes on one primary JavaScript thread. When intensive operations like payload transformation, crypto hashing, or sorting run on that thread, the frame rate degrades and user gestures lag. Running dedicated background Hermes instances decouples computational work from the UI layer, keeping animations locked at 60/120 FPS.
+-----------------------------------------------------------+
| MAIN THREAD |
| [ React Component Tree ] <---> [ UI Event Loop / 120 FPS ] |
+-----------------------------------------------------------+
|
(Shared Reactive C++ State)
|
+-----------------------------------------------------------+
| BACKGROUND RUNTIME |
| [ Isolated Hermes VM ] <---> [ Heavy Compute / Parsing ] |
+-----------------------------------------------------------+Common scenarios where background workers prevent frame drops include:
- Local Data Filtering & Sorting: Processing arrays with tens of thousands of items before rendering a virtualized list.
- Client-Side Cryptography: Encrypting sensitive payloads, hashing tokens, or generating keys.
- Data Ingestion & Sanitization: Parsing large API payloads or synchronizing offline SQLite datasets.
- Image & Buffer Processing: Parsing raw binary streams or generating local search indices.
Native Setup and Package Configuration
Multi-threaded Hermes workers require native initialization so secondary runtimes can resolve native dependencies and bindings. On iOS, native runtime headers are registered during early application bootstrapping in Swift, while Android requires registering runtime modules in MainApplication.kt. Completing these steps ensures background workers have isolated memory heaps with full native module support.
First, add the required dependencies:
npm install @react-native-runtimes/core @react-native-runtimes/state react-native-nitro-modulesiOS Configuration
1. Install CocoaPods Dependencies
Navigate to your ios folder and run pod install:
cd ios && bundle exec pod install2. Configure the Bridging Header
Expose the native threaded runtime header inside your Objective-C bridging header (MyApp-Bridging-Header.h):
// MyApp-Bridging-Header.h
#import <NativeComposeThreadedRuntime/ThreadedRuntime.h>[!IMPORTANT] Always import
ThreadedRuntime.hvia the Objective-C bridging header. Directly importing C++ module wrappers in Swift files can cause compiler issues with the Swift Clang importer.
3. Initialize in AppDelegate.swift
Configure the runtime factory before launching React Native:
// AppDelegate.swift
import UIKit
import React_RCTAppDelegate
@main
class AppDelegate: RCTAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
let delegate = ReactNativeDelegate()
let factory = RCTReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
// Initialize ThreadedRuntime prior to starting the host React Native instance
ThreadedRuntime.configure(withReactNativeDelegate: delegate, launchOptions: launchOptions)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}Android Configuration
Secondary Hermes instances on Android run outside the primary host package context. You must explicitly expose Nitro modules to the threaded runtime provider in MainApplication.kt.
// MainApplication.kt
package com.myapp
import android.app.Application
import com.facebook.react.ReactApplication
import com.nativecompose.threadedruntime.ThreadedRuntime
import com.margelo.nitro.NitroModulesPackage
class MainApplication : Application(), ReactApplication {
override fun onCreate() {
super.onCreate()
// Register packages available to secondary Hermes runtimes
ThreadedRuntime.setExtraReactPackagesProvider {
listOf(
NitroModulesPackage(),
)
}
loadReactNative(this)
}
}[!WARNING] If a worker runtime throws an unresolved module error on Android, ensure
NitroModulesPackage()is properly listed insidesetExtraReactPackagesProvider.
Executing Background Workloads with Isolated Runtimes
Spawning secondary runtimes via @react-native-runtimes/core creates isolated Hermes VMs that run concurrently with the main thread. Workloads are written as worklet functions executed asynchronously using .runAsync(), returning promises that resolve back to the caller. Proper lifecycle management ensures runtimes are cleanly disposed of when parent components unmount.
Here is a reusable React hook that initializes a dedicated worker runtime and offloads a mathematical calculation:
import { useEffect, useRef, useState, useCallback } from 'react';
import { createRuntime, type Runtime } from '@react-native-runtimes/core';
export function useBackgroundWorker() {
const runtimeRef = useRef<Runtime | null>(null);
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
// Spin up an isolated background Hermes runtime
const runtime = createRuntime({ name: 'ComputeWorker' });
runtimeRef.current = runtime;
// Clean up native runtime resources on unmount
return () => {
runtime.dispose();
runtimeRef.current = null;
};
}, []);
const calculate = useCallback(async (iterations: number): Promise<number> => {
if (!runtimeRef.current) {
throw new Error('Background runtime is not initialized');
}
setIsRunning(true);
try {
// Dispatch worklet function to the secondary thread
return await runtimeRef.current.runAsync((count: number) => {
'worklet';
let acc = 0;
for (let i = 0; i < count; i++) {
acc += Math.sqrt(i) * Math.cos(i);
}
return acc;
}, iterations);
} finally {
setIsRunning(false);
}
}, []);
return { calculate, isRunning };
}Because each runtime operates in its own isolated heap, values passed to the worklet function must be serializable or wrapped in shared memory containers.
High-Performance Cross-Runtime State Synchronization
Traditional cross-thread communication in mobile apps often incurs serialization and deserialization overhead when passing large payloads. The @react-native-runtimes/state package bypasses this bottleneck by storing reactive state in shared C++ memory accessible across threads. Background workers can write updates in real time while UI components subscribe directly to changes with zero bridge latency.
Below is an implementation tracking real-time batch parsing progress:
import React from 'react';
import { View, Text, Button, StyleSheet } from 'react-native';
import { createSharedState, useSharedState } from '@react-native-runtimes/state';
import { createRuntime } from '@react-native-runtimes/core';
// Create a thread-safe reactive state container
const batchState = createSharedState({
status: 'Idle',
progress: 0,
processedRecords: 0,
});
export function BatchProcessingView() {
// Subscribe component directly to shared C++ memory updates
const state = useSharedState(batchState);
const startBatch = async () => {
const worker = createRuntime({ name: 'BatchWorker' });
await worker.runAsync((shared) => {
'worklet';
shared.set({ status: 'Processing', progress: 0, processedRecords: 0 });
const total = 100000;
for (let i = 1; i <= total; i++) {
if (i % 25000 === 0) {
shared.set({
status: 'Processing',
progress: Math.round((i / total) * 100),
processedRecords: i,
});
}
}
shared.set({
status: 'Completed',
progress: 100,
processedRecords: total,
});
}, batchState);
worker.dispose();
};
return (
<View style={styles.container}>
<Text style={styles.title}>Worker Thread Progress</Text>
<Text style={styles.info}>Status: {state.status}</Text>
<Text style={styles.info}>Progress: {state.progress}%</Text>
<Text style={styles.info}>Processed: {state.processedRecords} records</Text>
<Button title="Start Batch Processing" onPress={startBatch} />
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 20 },
title: { fontSize: 18, fontWeight: 'bold', marginBottom: 12 },
info: { fontSize: 15, marginBottom: 6 },
});Practical Architecture: Persistent Background Processing Pipeline
Creating and destroying runtime engines per small operation adds unnecessary CPU allocation overhead in high-throughput mobile apps. A persistent singleton worker service maintains a warmed-up Hermes instance to handle queued data sanitization, batch filtering, and transformations on demand. This approach optimizes throughput while keeping background memory footprints predictable.
Here is a production-ready singleton service for transforming large dataset batches:
import { createRuntime, type Runtime } from '@react-native-runtimes/core';
export interface RawTelemetryItem {
id: string;
timestamp: number;
data: string;
}
export interface ProcessedItem {
id: string;
formattedTime: string;
preview: string;
}
export class TelemetryProcessingPipeline {
private runtime: Runtime | null = null;
public init(): void {
if (!this.runtime) {
this.runtime = createRuntime({ name: 'TelemetryPipeline' });
}
}
public async processBatch(items: RawTelemetryItem[]): Promise<ProcessedItem[]> {
if (!this.runtime) {
this.init();
}
return this.runtime!.runAsync((rawItems: RawTelemetryItem[]): ProcessedItem[] => {
'worklet';
return rawItems
.filter((item) => item.data && item.data.length > 0)
.map((item) => {
const date = new Date(item.timestamp);
return {
id: item.id,
formattedTime: date.toISOString(),
preview: item.data.slice(0, 80),
};
});
}, items);
}
public dispose(): void {
if (this.runtime) {
this.runtime.dispose();
this.runtime = null;
}
}
}
export const telemetryPipeline = new TelemetryProcessingPipeline();Memory Management and Thread Safety Best Practices
Isolated Hermes instances run with independent garbage collectors, meaning native references and thread lifecycles must be managed explicitly. Developers should reuse long-running workers for recurring pipelines and explicitly invoke .dispose() when temporary runtimes finish. Following strict data passing rules prevents native thread leaks and cross-thread memory corruption.
- Reuse Persistent Workers: For repetitive tasks (e.g., SQLite synchronization, analytics processing), maintain a long-lived runtime instance instead of spinning up new engines per request.
- Always Dispose Ephemeral Runtimes: Every call to
createRuntime()allocates dedicated OS thread resources and memory heaps. Ensure unmounted components trigger.dispose(). - Rely on Shared State for Large Payloads: Instead of passing massive JSON objects through
.runAsync()arguments, pass container references created with@react-native-runtimes/state. - Profile Native Thread Lifecycles: Validate thread termination and memory stabilization using Xcode Instruments (Allocations / Leaks) and Android Studio Profiler.
About the Author
Furkan Çetinkaya is a Mobile-focused Software Developer specializing in React Native, native bridge integrations (Kotlin & Swift), and supporting backend services. Experienced in maintaining high-impact mobile applications and developer SDKs.