hung-yueh
react-native-litert-lm
C++

High-performance on-device LLM inference for React Native, powered by LiteRT-LM and Nitro Modules

Last updated Aug 9, 2026
52
Stars
15
Forks
1
Issues
+2
Stars/day
Attention Score
48
Language breakdown
C++ 31.5%
TypeScript 20.4%
HTML 13.6%
Kotlin 9.8%
C 9.8%
Swift 9.1%
β–Έ Files click to expand
README

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 / thinking events 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 ship libOpenCL.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 with checkBackendSupport('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.engine queue so generation never blocks the JSI thread (lightweight accessors like getStats() use a synchronous hop onto that queue). Raw pointers are freed deterministically in deinit/close()/unload() for zero leaks. RSS read via machtaskbasic_info.
  • Android β€” stateless Kotlin conforming to HybridLiteRTLMSpec, with Proguard keep rules and optional libOpenCL.so probing 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 set org.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'] to example/ios/Podfile, run pod install, then cd example/ios && xcodebuild test -workspace LLMTest.xcworkspace -scheme react-native-litert-lm-Unit-Tests -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.6'.
The Jest tier also includes a config-parity contract test (every 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.

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 with am instrument: connectedAndroidTest uninstalls the test APK when it finishes, deleting the pushed model with it. Full command sequence is in the suite's header comment.
On-device memory scenarios (OOM prevention, pressure simulation, peak-RSS regression budget) are documented in 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.

Β© 2026 GitRepoTrend Β· hung-yueh/react-native-litert-lm Β· Updated daily from GitHub