Skip to content

Repository files navigation

react-native-scc

npmlicenseplatforms

Ultra-low-latency, persistent key-value storage for React Native and Expo. The core is written in Rust on top of scc (a lock-free concurrent hash map), and it reaches JavaScript through Nitro Modules, so a call from JS to Rust costs nanoseconds, not microseconds.

This project is also a statement of intent: bringing Rust closer to React Native. A Rust core behind Nitro Modules ships as an ordinary npm package — no toolchain for consumers, no compromise on performance — and this library is the proof that the pattern can go head-to-head with established C++ storage libraries.

The design goal is simple: every read is a RAM lookup, every write is durable, and the disk never sits on your hot path. Writes update the in-memory map synchronously and stream to a write-ahead log on a background thread; a hard kill can cost you the last few milliseconds of writes, but committed data is never corrupted.

  • All reads from RAM — the persistent store reads exactly as fast as a pure in-memory one
  • Durable by default — write-ahead log with group commits, atomic snapshot compaction, CRC-protected recovery
  • Sync and async APIs — sync for the hot path, *Async variants on Nitro's thread pool for anything that must not block the JS thread
  • Batch operationssetMany/getMany cross the bridge once for a whole record set
  • Transactions + namespaces — crash-atomic sync commits, prefix helpers, and scoped KV views
  • Encryption at rest — opt-in ChaCha20-Poly1305 per instance, snapshot and WAL both encrypted
  • TTL + eviction — per-key expiry with a background sweeper, optional maxEntries cap
  • Native change events — listeners, selectors, and hooks react to writes made through any handle of the same store
  • React hooksuseKVString, useKVNumber, useKVBoolean, useKVBuffer, useKVJSON
  • State-manager adapters — zustand persist, jotai atomWithKV, redux-persist engine as subpath exports
  • Zero-config persistence — storage lands in the platform app-data directory (iOS: Application Support, Android: filesDir)
  • Multiple independent stores — each id gets its own file pair, WAL thread, and durability settings
  • iOS + Android, Expo dev builds + bare React Native

Benchmarks

JS-visible synchronous API latency vs react-native-mmkv 4.3.2, measured by the example app in an iOS 26.3.1 simulator Release build. The table shows the range of per-launch medians from two independent launches. Each launch runs four balanced AB/BA trials with a physically recreated SCC store, cleared MMKV store, and verified 103-key seed per trial. Scalar cases run 100k iterations; 100-key cases run 1k iterations and report per-key latency. SCC uses its default relaxed WAL and performs two sequential, untimed flush barriers after every SCC sample; the second waits behind post-flush writer maintenance, so compaction cannot overlap the following MMKV sample.

Case (lower is better)SCCMMKV
setMany, 100 × 16 B, per key209–213 ns308 ns
getMany, 100 × 16 B, per key104–118 ns167–188 ns
Set string, 64 B358–438 ns530–581 ns
Get string, 64 B164–184 ns176–202 ns
Set string, 16 B292–365 ns291–316 ns
Get string, 16 B146–160 ns166–177 ns
Set number221–270 ns225–232 ns
Get number111 ns129–132 ns
Get missing key111–113 ns126–133 ns

These simulator launches are not a universal device claim. The write cases measure API-return latency, not fsync; call flush() when you need an explicit durability barrier. The setMany row compares one SCC bridge call with 100 independent MMKV scalar calls; it is a throughput comparison, not a transaction or crash-atomicity claim. Run the benchmark yourself with the in-app Run benchmark button; the app persists all raw samples and methodology metadata. For automated Release runs, set EXPO_PUBLIC_SCC_AUTORUN_BENCHMARK=1 before building.

Install

npm install react-native-scc-storage react-native-nitro-modules

Expo

The config plugin declares Expo SDK 57 or newer as an optional peer.

{ "plugins": ["react-native-scc-storage"] }
npx expo prebuild
npx expo run:ios
npx expo run:android

Expo Go is not supported (native code) — use a dev build.

Bare React Native

cd ios && pod install

Prebuilt Rust static libraries ship with the package. If they are missing (e.g. a source checkout), the build scripts compile them automatically — that path requires a Rust toolchain with the iOS/Android targets installed.

Quick start

import{createKV}from'react-native-scc-storage'constkv=createKV()// persistent, id 'default'// sync — the hot pathkv.set('user.name','Ada')kv.set('user.score',42.5)kv.set('user.premium',true)kv.setJSON('user.prefs',{theme: 'dark'})kv.getString('user.name')// 'Ada'kv.getNumber('user.score')// 42.5kv.getJSON<{theme: string}>('user.prefs')// { theme: 'dark' }// async — same store, Nitro thread poolawaitkv.setAsync('big.blob',someArrayBuffer)constblob=awaitkv.getBufferAsync('big.blob')awaitkv.flushAsync()// durability barrier// batch — one bridge crossingkv.setMany({a: '1',b: '2',c: '3'})kv.getMany(['a','b','missing'])// ['1', '2', undefined]

Instances

Every id is an independent store with its own files (<id>.snap + <id>.wal), its own background writer, and its own settings. Opening the same id twice returns the same underlying store.

constsettings=createKV({id: 'settings'})constvault=createKV({id: 'vault',durability: 'strict'})// fsync every commitconstcache=createKV({id: 'cache'})// relaxed (default): ~1s fsyncconstui=createKV({id: 'ui',persistence: 'none'})// pure in-memory, no files

Options: id, path (override the storage directory), persistence: 'wal' | 'none', durability: 'relaxed' | 'strict', recreate (wipe on open), encryptionKey (see below), maxEntries, and ttlSweepIntervalMs.

Encryption at rest

constvault=createKV({id: 'vault',encryptionKey: 'my-secret-passphrase'})

Everything the instance writes to disk — snapshot and write-ahead log alike — is encrypted with ChaCha20-Poly1305; the 256-bit cipher key is derived from the passphrase with SHA-256. Opening an encrypted store with a wrong key (or without one) fails without touching the files, and opening a plaintext store with a key fails too, so a configuration mistake can never silently corrupt or rewrite data. Store the passphrase in the platform keystore (Keychain / Android Keystore) — the library deliberately does not manage key storage for you.

TTL

kv.set('session.token',token,{ttlMs: 15*60*1000})kv.setJSON('cache.profile',profile,{ttlMs: 60_000})

Expired keys read as missing immediately (get, contains, getAllKeys all agree), and a background sweeper physically reclaims them — also from disk — on the instance's WAL thread (sweep interval: 30 s). TTL persists across restarts: a key set with a 1-hour TTL is still gone after a kill + relaunch past its deadline.

Eviction

constcache=createKV({id: 'cache',maxEntries: 10_000,ttlSweepIntervalMs: 5_000,})

When the store outgrows maxEntries, the sweeper evicts expired keys first, then arbitrary live keys until it fits. Eviction order is unspecified (not LRU) — use it as a safety cap, not a cache policy.

Transactions

constnext=kv.transaction((tx)=>{constcurrent=tx.getNumber('counter')??0tx.set('counter',current+1)tx.setJSON('counter.meta',{updatedAt: Date.now()})returncurrent+1})

Transactions are synchronous and crash-atomic for recovery: the callback stages writes in JS and sees its own staged values, then commits them as one WAL record, so replay after a crash applies either all staged writes or none. Concurrent readers do not get multi-key isolation while the in-memory commit is being applied. Async callbacks are rejected so the library never holds transactional state across an await.

Prefixes and namespaces

constuser=kv.namespace('user:123')user.set('name','Ada')// stores user:123:nameuser.setJSON('prefs',{theme: 'dark'})user.getAllKeys()// ['name', 'prefs']user.clearAll()// deletes only user:123:* keyskv.getKeysByPrefix('user:123:')// full keyskv.deleteByPrefix('cache:')

Namespaces are lightweight JS views over the same underlying store. They do not create extra files or WAL threads.

Hooks

import{useKVNumber}from'react-native-scc-storage'functionCounter(){const[count,setCount]=useKVNumber('counter')return<Buttontitle={`${count??0}`}onPress={()=>setCount((count??0)+1)}/>}

Each hook returns [value, setValue]; calling setValue(undefined) deletes the key. Hooks re-render on any write to the key, including writes made through other KV objects opened with the same id.

consttheme=useKVSelector<{theme?: string},string|undefined>('settings',(settings)=>settings?.theme)

Change listener

The listener fires for every mutation of the underlying store, from any handle. key is null after clearAll ("everything changed"). Delivery is asynchronous on the JS thread.

constsub=kv.addOnValueChangedListener((key)=>{console.log(key===null ? 'store cleared' : `changed: ${key}`)})sub.remove()

Selectors sit on top of the same listener and only fire when the selected value changes:

constsub=kv.observeJSON('settings',(settings: {theme?: string}|undefined)=>settings?.theme,(theme)=>console.log('theme changed',theme))

Adapters

One package, three subpath exports. zustand and jotai are optional peer dependencies — install only what you use.

zustand

import{create}from'zustand'import{persist,createJSONStorage}from'zustand/middleware'import{sccStateStorage}from'react-native-scc-storage/zustand'constuseStore=create(persist((set)=>({bears: 0}),{name: 'bears',storage: createJSONStorage(()=>sccStateStorage()),}))

The storage is synchronous, so zustand hydrates without an async gap — no loading flicker, no onRehydrateStorage dance.

jotai

import{atomWithKV}from'react-native-scc-storage/jotai'constcounterAtom=atomWithKV('counter',0)

Reads synchronously on init (getOnInit) and reacts to writes made outside jotai — including other KV handles — via the native change listener.

redux-persist

import{createSccStorage}from'react-native-scc-storage/redux'constpersistedReducer=persistReducer({key: 'root',storage: createSccStorage()},rootReducer)

API

syncasyncreturns
set(key, value)setAsyncvoid — value: string | number | boolean | ArrayBuffer
setJSON(key, value)setJSONAsyncvoid
setMany(entries)setManyAsyncvoidRecord<string, string>
transaction(callback)callback return value
namespace(prefix)scoped KV view
getKeysByPrefix(prefix)string[]
deleteByPrefix(prefix)number
observeJSON(key, selector, listener)KVSubscription
getString(key)getStringAsyncstring | undefined
getNumber(key)getNumberAsyncnumber | undefined
getBoolean(key)getBooleanAsyncboolean | undefined
getBuffer(key)getBufferAsyncArrayBuffer | undefined
getJSON<T>(key)getJSONAsyncT | undefined
getMany(keys)getManyAsync(string | undefined)[]
contains(key)containsAsyncboolean
delete(key)deleteAsyncboolean
getAllKeys()getAllKeysAsyncstring[]
clearAll()clearAllAsyncnumber sync, void async
flush()flushAsyncvoid — blocks until fsynced
sizenumber
close()void

Reading a key that holds a different type returns undefined (matching react-native-mmkv). Numbers are IEEE-754 doubles, i.e. exactly JS number semantics.

Durability model

Writes update the in-memory map synchronously, then stream to a write-ahead log on a dedicated background thread. The writer batches records into group commits (8 ms or 128 KiB, whichever comes first). With durability: 'relaxed' (default) the log is fsynced about once per second; with 'strict' every group commit is fsynced. flush() / flushAsync() is the explicit barrier: it returns only after everything written so far is on disk.

On restart the store recovers from snapshot + WAL replay. Every record carries a CRC32; a torn tail from a hard kill is truncated and recovery continues — committed data is never lost or corrupted. When the WAL outgrows max(4 MiB, 2 × snapshot size), the background writer compacts it into an atomically replaced snapshot, keeping recovery files bounded without moving disk I/O onto the JS thread.

Architecture

TypeScript (KV class, hooks, adapters)
└─ Nitro Modules (JSI, sync calls, zero-copy where possible)
└─ C++ HybridObjects
└─ C FFI (cbindgen, panic-safe boundary)
└─ Rust core: scc::HashMap (lock-free reads) + WAL writer thread

The Rust core is an independent crate (crates/kv-core) with its own test suite: crash-recovery tests that truncate the WAL at every byte offset, multi-threaded stress tests racing writers against compaction, and criterion benchmarks. The C ABI layer (crates/kv-ffi) wraps every entry point in catch_unwind, so a Rust panic can never unwind across the language boundary.

Development

npm install
npm run specs # tsc + nitrogen codegen
npm test# jest (KV + hooks + adapters against a mock native layer)
cargo test --workspace # Rust core + FFI suites
cargo bench -p kv-core # criterion benchmarks
npm run rust:build # cross-compile static libs for iOS + Android

The example app under example/ runs a full on-device self-test (sync/async round-trips, persistence across launches, cross-handle change events) and the MMKV comparison benchmark with live charts.

License

MIT

About

Rust-powered ultra-fast persistent key-value storage for React Native and Expo, via Nitro Modules.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages