High-performance on-device LLM inference for React Native, powered by LiteRT-LM and Nitro Modules
react-native-litert-lm
High-performance on-device LLM inference for React Native, powered by LiteRT-LM and Nitro Modules. Optimized for Gemma 4 and other on-device models β with first-class memory safety so a 1β4 GB model can't silently OOM-kill your app.
Highlights
- π‘οΈ Crash-free memory handling β pre-flight estimation, live tracking, context forecasting, OS pressure warnings, budgets, and deterministic
unload(). See below. - β‘ Binary multimodal input β pass image/audio as native
ArrayBuffers, no base64 heap blow-up (buffers are staged to temp files for the engine's file-based API). - π§© Typed streaming events β
token/toolCall/thinkingevents on both platforms, parsed from the engine's channel markers. - π― Guaranteed structured output β constrain any response to a JSON Schema or regex (constrained decoding, both platforms) β the output cannot come back malformed.
- π¬ Multiple conversations, one engine β independent chats without loading the model twice.
- ποΈ GPU acceleration β Metal (iOS), OpenCL delegate (Android), with automatic CPU fallback.
- π§ Speculative decoding & tool calling β multi-token prediction and JSON-schema function calls.
- π₯ Automatic model download β HTTPS download with progress and local caching.
Installation
npm install react-native-litert-lm react-native-nitro-modules
Expo β add the plugin to app.json, then prebuild:
{ "expo": { "plugins": ["react-native-litert-lm"], "android": { "minSdkVersion": 26 } } }
npx expo prebuild
npx expo run:ios # or run:android
Bare React Native β cd ios && pod install (iOS) / cd android && ./gradlew clean (Android).
Only ARM devices/simulators are supported. x86_64 Android emulators are not.
Quick Start
The useModel hook manages the full lifecycle β download, load, inference, cleanup β and exposes memory state reactively.
import { useModel, GEMMA4E2B_IT } from "react-native-litert-lm";
function Chat() { const { model, isReady, downloadProgress, error, memoryEstimate } = useModel( GEMMA4E2B_IT, { backend: "cpu", systemPrompt: "You are a helpful assistant.", enableMemoryTracking: true }, );
if (error) return <Text>{error}</Text>; if (!isReady) return <Text>Loading⦠{Math.round(downloadProgress * 100)}%</Text>;
const ask = async () => console.log(await model.sendMessage("Hello!")); return <Button title="Generate" onPress={ask} />; }
Prefer imperative control? Use createLLM():
import { createLLM } from "react-native-litert-lm";
const llm = createLLM(); await llm.loadModel("https://example.com/model.litertlm", { backend: "gpu" }); const reply = await llm.sendMessage("What is the capital of France?"); llm.unload(); // free the engine; llm stays reusable
Memory Handling
On-device LLMs are the easiest way to get an app OOM-killed: a model that fits on one phone is killed by iOS Jetsam / Android LMK on another. This library turns "will it fit?" into a first-class, testable question across three layers β predict β watch β react.
1. Predict β pre-flight estimation
loadModel() estimates weights + KV cache + overhead against real OS headroom (jetsam-aware osprocavailable_memory on iOS, ActivityManager.MemoryInfo on Android) and rejects with a typed MemoryError instead of letting the OS kill your app:
import { isMemoryError } from "react-native-litert-lm";
try { await llm.loadModel(modelUrl, { maxContextTokens: 8192 }); } catch (e) { if (isMemoryError(e)) { console.log(e.estimate.verdict); // 'safe' | 'tight' | 'critical' console.log(e.estimate.recommendation); // how to make it fit await llm.loadModel(modelUrl, { maxContextTokens: 2048 }); // retry smaller } }
Estimate before downloading anything to drive a model picker, and pass { forceLoad: true } to skip the check:
import { estimateMemory } from "react-native-litert-lm";
const estimate = estimateMemory({ modelFileSizeBytes: 2.58e9, availableMemoryBytes: llm.getMemoryUsage().availableMemoryBytes, config: { backend: "gpu", maxContextTokens: 4096 }, }); if (estimate.verdict !== "safe") suggestSmallerModel();
2. Watch β live usage & forecasting
getMemoryUsage() reads real OS metrics (RSS, native heap, available memory) β no estimation. With enableMemoryTracking, snapshots are recorded into a native-backed ring buffer after every inference:
const llm = createLLM({ enableMemoryTracking: true, maxMemorySnapshots: 256 });
// β¦ after inference β¦
const { peakResidentBytes, currentResidentBytes } = llm.memoryTracker!.getSummary();
getMemoryForecast() combines the engine's KV-cache token count (exact on iOS; approximated on Android, where the SDK doesn't expose tokenizer counts) with the cost model to warn before the context window runs out:
const forecast = llm.getMemoryForecast();
// { contextTokensUsed, remainingTokens, contextUsedFraction, kvCacheBytesUsed, nearingLimit }
if (forecast?.nearingLimit) summarizeHistoryOrWarn();
3. React β pressure warnings, budgets & teardown
Subscribe to real OS memory-pressure signals (onTrimMemory on Android, dispatch memory-pressure source on iOS) β the callback fires with level 'moderate' or 'critical' β or set app-defined budgets:
llm.setMemoryWarningCallback((level, usage) => {
if (level === "critical") llm.unload(); // free ~GBs deterministically
});
const llm = createLLM({ enableMemoryTracking: true, memoryBudget: { warnAtFraction: 0.75, criticalAtFraction: 0.9, onBudgetExceeded: (level) => console.warn(memory ${level}), }, });
unload() releases the engine (freeing gigabytes) while keeping the instance reusable β don't wait for GC to reclaim a multi-GB model.
Tuning knobs
Every knob's memory impact, documented. maxContextTokens is the biggest lever.
| Knob | Effect | Platform | | --- | --- | --- | | maxContextTokens | KV-cache size β the biggest lever | both | | activationDataType: 'f16' | ~halves activation/KV memory | iOS | | prefillChunkSize | caps peak prefill activation memory | iOS | | numThreads | CPU memory-bandwidth pressure | iOS | | execute(β¦, { maxOutputTokens }) | per-message output cap | both | | loraPath | one base model + small adapters | both |
With the useModel hook
All of the above is reactive β memoryEstimate, memoryForecast, and memoryWarning are returned alongside memorySummary, updating automatically as you load and generate.
Inference
Streaming
llm.sendMessageAsync("Tell me a story", (token, done) => {
process.stdout.write(token);
if (done) console.log("\nβ done β");
});
Typed streaming events (tool calls & thinking)
executeWithEvents() turns the raw token stream into typed events. Tool calls the model emits arrive as toolCall events on both platforms; setting streamToolCalls: true additionally streams tool-call and reasoning tokens as they are generated (iOS only β on Android a tool call surfaces once it is complete, which is what most callers want anyway):
await llm.loadModel(modelUrl, { tools }); // + streamToolCalls: true for token-level streaming on iOS
await llm.executeWithEvents([{ type: "text", text: "Weather in Tokyo?" }], (event) => { switch (event.type) { case "token": ui.appendText(event.text); break; case "toolCall": toolBuffer += event.text; break; case "thinking": ui.showReasoning(event.text); break; } if (event.done) runTool(JSON.parse(toolBuffer)); });
Markers default to <toolcall>β¦</toolcall> / <thinking>β¦</thinking> and are configurable via createLLM({ streamChannels }).
Multimodal (binary buffers)
Pass native-backed ArrayBuffers directly β no base64 encoding. (Internally the engine's API is file-based, so buffers are staged to temp files that are cleaned up after inference.)
const buf = await (await fetch(Image.resolveAssetSource(require("./photo.jpg")).uri)).arrayBuffer();
const reply = await llm.sendMultimodalMessage([ { type: "image", imageBuffer: buf }, { type: "text", text: "Describe this image." }, ]);
Path-based helpers also exist: sendMessageWithImage(text, path) and sendMessageWithAudio(text, path). Multimodal requires a multimodal model (e.g. Gemma 4 E2B, Gemma 3n).
Speculative decoding & tool calling
useModel(GEMMA4E2B_IT, {
enableSpeculativeDecoding: true, // multi-token prediction, if the model supports it
tools: [{
name: "getcurrentweather",
description: "Get the current weather for a location",
parametersJson: JSON.stringify({
type: "object",
properties: { location: { type: "string" }, unit: { type: "string", enum: ["celsius", "fahrenheit"] } },
required: ["location"],
}),
}],
});
Structured output (JSON Schema / regex)
With enableStructuredOutput: true, any message can constrain its response via constrained decoding (LLGuidance, LiteRT-LM 0.15+) β the engine guarantees the output matches, on both platforms:
const llm = createLLM();
await llm.loadModel(GEMMA4E2B_IT, {
enableStructuredOutput: true,
temperature: 0, // greedy sampling improves schema adherence
});
const json = await llm.execute( [{ type: 'text', text: 'Extract: "Ada Lovelace, born 1815, London"' }], undefined, { responseSchema: JSON.stringify({ type: 'object', properties: { name: { type: 'string' }, birthYear: { type: 'number' }, city: { type: 'string' } }, required: ['name', 'birthYear', 'city'], }), }, ); const person = JSON.parse(json); // guaranteed to parse
// Or a regex constraint: await llm.execute([{ type: 'text', text: 'Pick a priority.' }], undefined, { responseRegex: 'P[0-3]', });
responseSchema takes precedence when both are set. Using either without enableStructuredOutput rejects with a clear error.
Generation controls (thinking, anti-repetition)
Per message via ExecuteOptions (both platforms, LiteRT-LM 0.15+):
await llm.execute(parts, onToken, {
maxOutputTokens: 256, // per-message output cap
thinking: { enabled: true, tokenBudget: 512 }, // reasoning budget (Gemma 4)
repetitionPenalty: 1.2, // β₯ 1.0, HuggingFace-style multiplicative
presencePenalty: 0.5, // OpenAI-style subtractive
frequencyPenalty: 0.3,
noRepeatNgramSize: 3, // ban exact 3-gram repeats
suppressTokens: [128010], // token IDs forced to -inf (iOS only)
});
suppressTokens is iOS only. On Android it is ignored with a warning:
LiteRT-LM 0.15.0's SuppressTokensConfig JNI binding looks up a
Kotlin-mangled internal accessor and aborts the process, so the library
refuses to use it there until upstream fixes it.
Session-wide thinking defaults go in the load config: loadModel(url, { thinking: { tokenBudget: 1024 } }). Thinking content still streams as typed thinking events through executeWithEvents().
Multiple conversations, one engine
createConversation() gives you independent chats sharing a single loaded model β no double model load, ideal for "New Chat" UIs and agent side-chains:
const support = llm.createConversation({ systemPrompt: 'You are a support agent.' });
const summarizer = llm.createConversation({ systemPrompt: 'You summarize tersely.' });
await support.execute([{ type: 'text', text: 'My app crashes on launch.' }]); await summarizer.execute([{ type: 'text', text: 'Summarize: β¦' }]); // context switch await support.execute([{ type: 'text', text: 'It happens on iOS 18.' }]); // remembers the crash report
support.getHistory(); // this conversation's transcript await summarizer.release(); // drop a side-chain when done
How it works: the engine holds one native context at a time. Switching conversations replays the target's transcript into a fresh context, so the next message after a switch pays a re-prefill cost (roughly seconds on long transcripts β engine-side prefix caching that would make this near-free is in progress upstream). Frequent A/B ping-ponging with long histories will feel it; occasional switching won't. Multimodal turns replay as [Image]/[Audio] text placeholders. Once conversations are in use, inference calls are serialized so a switch can never interrupt a generation; top-level llm.execute() acts as its own "default" conversation.
Supported Models
All exported URLs are public β no auth required. Pass any to useModel() / loadModel().
| Constant | Model | Size | Min RAM | Source | | --- | --- | --- | --- | --- | | GEMMA4E2B_IT | Gemma 4 E2B (multimodal) | 2.58 GB | 4 GB+ | HuggingFace | | GEMMA4E4B_IT | Gemma 4 E4B (higher quality) | 3.65 GB | 6 GB+ | HuggingFace | | GEMMA3NE2BITINT4 | Gemma 3n E2B (int4, multimodal) | ~3.66 GB | 6 GB+ | litert.dev |
Other .litertlm models (Gemma 3 1B, Phi-4 Mini, Qwen 2.5 1.5B) download manually from HuggingFace.
iOS: models over ~2 GB need the Extended Virtual Addressing entitlement β that includes all three models above. For a sub-2 GB option, download Gemma 3 1B manually from HuggingFace.
API Reference
createLLM(options?) β instance. Options: enableMemoryTracking, maxMemorySnapshots (default 256), memoryBudget, streamChannels.
loadModel(path, config?) β Promise<void>. path is a local path or HTTPS URL.
| Config | Default | Notes | | --- | --- | --- | | backend | 'cpu' | 'cpu' \| 'gpu' \| 'npu' (auto-fallback to CPU) | | systemPrompt | β | System prompt | | temperature / topK / topP | 0.7 / 40 / 0.95 | Sampling | | maxContextTokens | 4096 | Total KV-cache budget (tokens) | | maxOutputTokens | 1024 | Max tokens generated per response | | streamToolCalls | false | Stream tool-call/thinking tokens mid-generation (iOS only; completed tool calls surface as typed events on both) | | enableStructuredOutput | false | Initialize constrained decoding for per-message responseSchema/responseRegex | | thinking | engine default | { enabled, tokenBudget } reasoning controls (Gemma 4) | | forceLoad | false | Skip the pre-flight memory check | | memory tuning | β | numThreads, prefillChunkSize, activationDataType, loraPath β see Tuning knobs |
Inference: sendMessage(text), sendMessageAsync(text, cb), sendMessageWithImage/Audio(text, path), sendMultimodalMessage(parts), execute(parts, onToken?, options?), executeWithEvents(parts, onEvent, options?).
Conversations: createConversation(options?) β handle with execute, executeWithEvents, getHistory(), release() β independent chats sharing one engine (see Multiple conversations).
Memory: estimateMemory(inputs), getMemoryUsage(), getMemoryForecast(), getContextTokenCount(), setMemoryWarningCallback(cb) / clearMemoryWarningCallback(), memoryTracker.
Lifecycle: getStats(), getHistory(), resetConversation(), unload(), close(), deleteModel(fileName).
Utilities: checkBackendSupport(backend), checkMultimodalSupport(), getRecommendedBackend() β each returns a warning string (or undefined) so you can gate features before loading.
Requirements & Platform Support
| | | | --- | --- | | React Native | 0.76+ | | react-native-nitro-modules | 0.36.0+ | | LiteRT-LM engine | 0.15.0 | | Android | API 26+, arm64-v8a β CPU (all), GPU (where OpenCL is present), NPU | | iOS | 15.1+, arm64 β CPU, GPU (Metal; auto-fallback to CPU) |
Android GPU requires the device to shiplibOpenCL.so, which varies by vendor and SoC rather than by brand (present on a Galaxy S22 / Snapdragon 8 Gen 1, absent on plenty of other devices). Probe it withcheckBackendSupport('gpu')before committing to the GPU backend; the engine auto-falls back to CPU either way.
iOS Entitlements
Models over ~2 GB need Extended Virtual Addressing or iOS caps virtual memory at ~2 GB and Jetsam kills the app. Add to your .entitlements (requires a paid Apple Developer account):
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
<true/>
Architecture
Nitro Modules (JSI) bridges TypeScript to a per-platform native engine:
React Native (TypeScript)
β Nitro JSI bindings (HybridLiteRTLMSpec)
ββββββ΄ββββββββββββββββββββββ
iOS (Swift Direct FFI) Android (Kotlin)
CLiteRTLM.xcframework litertlm-android AAR
- iOS β native Swift calling the C FFI directly; inference, load, and unload are dispatched on a serial
dev.litert.enginequeue so generation never blocks the JSI thread (lightweight accessors likegetStats()use a synchronous hop onto that queue). Raw pointers are freed deterministically indeinit/close()/unload()for zero leaks. RSS read viamachtaskbasic_info. - Android β stateless Kotlin conforming to
HybridLiteRTLMSpec, with Proguard keep rules and optionallibOpenCL.soprobing for the GPU delegate (with a CPU fallback chain when engine creation fails).
Testing
Multi-tier suite that runs on CI without a device:
- JS/TS (Jest):
npm testβ memory estimator (golden values), forecast/budget logic, stream-event parsing, ring-buffer tracker, hook & factory behavior, HTTPS guard. - Android (Robolectric):
cd example/android && ./gradlew :react-native-litert-lm:testDebugUnitTestβ covers path-traversal/HTTPS guards, memory telemetry, and error paths. Requires a JDK 21+ test launcher (the litertlm-android AAR is built for Java 21); Gradle picks one up via toolchain auto-detection, or setorg.gradle.java.installations.paths. - iOS (XCTest): the test spec isn't part of Expo's generated Podfile β add
pod 'react-native-litert-lm', :path => '../..', :testspecs => ['Tests']toexample/ios/Podfile, runpod install, thencd example/ios && xcodebuild test -workspace LLMTest.xcworkspace -scheme react-native-litert-lm-Unit-Tests -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.6'.
LLMConfig key must be forwarded by useModel or explicitly excluded) and a device-baseline guardrail that validates the memory cost model against peak-RSS numbers recorded in scripts/memory-baseline.json.
Real-inference integration suites (opt-in)
Both platforms have a suite that loads an actual .litertlm bundle and asserts on real generations β streaming, system prompt, schema/regex constrained output, transcript replay, generation controls and tool-call events. Both skip cleanly when no model is present, so CI stays device-free.
- iOS (
ios/Tests/HybridLiteRTLMIntegrationTests.swift) β runs in the simulator:
cd example/ios && TESTRUNNERLITERTLMTESTMODEL=$HOME/.litert-models/gemma-4-E2B-it.litertlm xcodebuild test -workspace LLMTest.xcworkspace -scheme react-native-litert-lm-Unit-Tests -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17'
- Android (
android/src/androidTest/β¦/HybridLiteRTLMInstrumentedTest.kt) β needs a real device or emulator, since Robolectric cannot run the engine. Install the APK before pushing the model and drive it witham instrument:connectedAndroidTestuninstalls the test APK when it finishes, deleting the pushed model with it. Full command sequence is in the suite's header comment.
scripts/device-memory-scenarios.md, and the scripted example-app integration pass lives in scripts/e2e-example-flow.md.
Example App
example/ is a full showcase app β Chat + Memory dashboard (pre-flight verdict, live RSS sparkline, context forecast, pressure warnings), typed streaming events, and the tuning knobs. Run it with npm run build, then cd example && npm install && npx expo prebuild --clean && npx expo run:ios.
License
Code is MIT.
β οΈ AI Model Disclaimer
This library is an execution engine; the models are not distributed with it and carry their own licenses β Gemma, Llama 3, Qwen, Phi. By downloading a model you accept its license and acceptable-use policy. The author takes no responsibility for model outputs.