Blazing-fast Protocol Buffers for React Native, powered by Nitro Modules and nanopb (C++).
Encode and decode protobuf messages in C++ directly over JSI - no bridge, no
serialization to JSON in between. You hand it a plain JS object, it returns an
ArrayBuffer (and back). On Hermes it encodes ~2-7× faster than protobuf.js
and produces payloads ~3× smaller than JSON. See PERFORMANCE.md.
- ⚡ Native C++ codec (nanopb) over JSI - no bridge round-trips.
- 📦 Compact wire format - typically ~3× smaller than JSON.
- 🔧 Zero-install code generation - bundled
protoc; the nanopb generator is installed automatically on first run. Nobrew/pipsetup. - 🪄 No hand-written
.options- sensible field-size defaults are applied automatically; override per field or globally only when you want to. - 🔒 Generated TypeScript types - a typed
Message.encode/decodeobject per message plus a genericencode/decode, fully inferred. - 🧩 Broad schema coverage - proto3 + proto2, every scalar type,
enum, nested messages,repeated,oneof,map<K,V>. - 🕒 Well-known types -
Timestamp,Duration,Empty,FieldMaskand the scalar wrappers, with natural JS mapping (Timestamp⇄Date,Duration⇄ ms). - 🧰 Runtime helpers -
byteLength(), field reflection, canonical proto3 JSON (toJson/fromJson), and typedProtobufErrors. - 🔢 Type options -
bigintfor 64-bit fields, string-literal enums. - 👀 Watch mode + Metro plugin - regenerate on
.protochanges / Metro start. - 📱 iOS & Android, New Architecture · 🧩 Expo config plugin.
- React Native with the New Architecture enabled.
react-native-nitro-modules(peer dependency).- Node.js 18+ and
python3(used once to install the code generator; ships with macOS and Linux).
npm install @klaappinc/react-native-nitro-protobuf react-native-nitro-modulescd ios && pod installExpo: add the config plugin to
app.jsonand runnpx expo prebuild.{ "plugins": [ ["@klaappinc/react-native-nitro-protobuf", { "protoDir": "proto" }] ] }
npx react-native-nitro-protobuf init # scaffold proto/, config, and a script
# edit proto/*.proto …
npm run proto:generate # generate C sources, registry, and TS types
cd ios && pod install # (re)build with the generated codeDefine a message:
// proto/example.proto
syntax = "proto3";
package acme;
message User {
uint32 id = 1;
string name = 2;
repeated int32 scores = 3;
bool active = 4;
}Use the generated, fully-typed per-message API - no magic strings:
import { AcmeUser } from './generated/nitro-protobuf'
const user: AcmeUser = { id: 1, name: 'Ada', scores: [10, 20], active: true }
const bytes = AcmeUser.encode(user) // ArrayBuffer
const back = AcmeUser.decode(bytes) // AcmeUserA generic, name-keyed API is also generated (handy for dynamic dispatch):
import { encode, decode } from './generated/nitro-protobuf'
const bytes = encode('acme.User', user) // name is checked against the schema
const back = decode('acme.User', bytes) // typed as AcmeUserproto:generate needs no system tools: it uses a bundled protoc
(grpc-tools) and installs the matching nanopb generator on first run. It writes,
into the package's generated/ directory (compiled by the pod / CMake):
*.pb.h/*.pb.c(nanopb) andnitro_protobuf_registry.cppnitro-protobuf.ts(totsOut) - typed interfaces plusencode/decodefor every message
Optional nitro-protobuf.config.json at your project root (CLI flags override it):
{
"protoDir": "proto",
"tsOut": "./generated",
"defaults": { "maxLength": 256, "maxSize": 256, "maxCount": 16 },
"bigint": false,
"enums": "number"
}bigint: true(or--bigint) types 64-bit fields asbigintinstead of the default precision-safe decimalstring.enums: "string"(or--enums string) types enums as their value-name string-literal union (e.g.'ADMIN' | 'USER') instead ofnumber.
The nanopb C sources go to the package's generated/ (where the pod / CMake
compile them); the typed nitro-protobuf.ts goes to tsOut. Set tsOut to a
folder inside your project (e.g. ./generated) so you can import it directly -
the examples below assume ./generated/nitro-protobuf.
nanopb stores fields in fixed-size C structs, so every string, bytes, and
repeated field needs a maximum. These defaults are applied automatically:
| Option | Applies to | Default |
|---|---|---|
max_length |
string |
256 |
max_size |
bytes |
256 |
max_count |
repeated |
16 |
Override a specific field with a standard nanopb <name>.options file next to
your .proto (specific entries win over the defaults):
acme.User.name max_length: 32
acme.User.avatar max_size: 1024
Pass --strict to require an explicit option for every field (no defaults) -
useful for tightly memory-constrained targets.
react-native-nitro-protobuf <command> [options]
Commands:
init Scaffold proto/, config, and a generate script
generate Generate nanopb sources, registry, and TS types (default)
Options:
--protoDir <path> Directory with .proto files (default: ./proto)
--outDir <path> Output for generated C/registry (default: <module>/generated)
--tsOut <path> Output for generated TS types (default: outDir)
--protoPath <path> Extra protoc import path (repeatable)
--protoc <path> Use a specific protoc (default: bundled)
--nanopb <path> Use a specific protoc-gen-nanopb (default: auto-installed)
--strict Require explicit .options for every static field
--skipProtoc Generate the registry + TS types only (no nanopb sources)
--bigint Type 64-bit fields as bigint (default: decimal string)
--enums <mode> Enum representation: "string" or "number" (default)
--watch, -w Regenerate on .proto changes (debounced)
Run npm run proto:watch during development to regenerate types as you edit
.proto files. Note: TypeScript changes hot-reload through Metro, but native
schema changes (new messages/fields) still require a rebuild (pod install /
Gradle) since the nanopb C structs are compiled into the app.
The generated module gives you type-safe helpers; you can also call the runtime object directly (untyped):
import { NitroProtobuf } from '@klaappinc/react-native-nitro-protobuf'
const bytes = NitroProtobuf.encode('acme.User', { id: 1, name: 'Ada' })
const user = NitroProtobuf.decode('acme.User', bytes)
const names = NitroProtobuf.listMessages()| proto type | JS input | JS output |
|---|---|---|
bool |
boolean |
boolean |
int32 / uint32 / sint*32 / fixed32 / enum |
number (or numeric string) |
number |
int64 / uint64 / fixed64 / sint64 |
numeric string | numeric string |
float / double |
number (or numeric string) |
number |
string |
string |
string |
bytes |
base64 string or number[] |
base64 string |
| message | object | object |
| repeated | array | array |
google.protobuf.Timestamp |
Date or ISO string |
ISO string |
google.protobuf.Duration |
number (milliseconds) |
number (milliseconds) |
- 64-bit integers map to decimal strings to avoid JS precision loss.
bytesdoes not accept aUint8Array(rejected at the JSI boundary) - pass a base64 string or anumber[]. This differs fromprotobufjs.- Numeric strings must be fully numeric;
"12abc"and"1.2.3"are rejected.
google.protobuf.Timestamp, Duration, Empty, FieldMask and the scalar
wrappers (StringValue, Int32Value, …) work out of the box - just import them:
import "google/protobuf/timestamp.proto";
import "google/protobuf/duration.proto";
message Session {
google.protobuf.Timestamp created_at = 1;
google.protobuf.Duration ttl = 2;
}import { AcmeSession } from './generated/nitro-protobuf'
const bytes = AcmeSession.encode({ created_at: new Date(), ttl: 30_000 }) // ms
const s = AcmeSession.decode(bytes)
s.created_at // ISO string, e.g. "2026-05-21T10:00:00.000Z"
s.ttl // 30000 (ms)Timestamp accepts a Date or ISO string and decodes to an ISO string;
Duration is milliseconds. Struct, Value, ListValue and Any are not
supported (they need recursive Value); see ROADMAP.md.
oneof is supported: each member is an optional field on the message: set one,
and only the set member is encoded and surfaced on decode. map<K, V> is
supported too: a map field maps to a JS object ({ [key]: value }).
The generated module also gives you:
import { AcmeUser, byteLength } from './generated/nitro-protobuf'
// Encoded size without allocating the buffer (typed + per-message).
AcmeUser.byteLength(user) // number
byteLength('acme.User', user) // generic
// Lightweight reflection: field metadata.
AcmeUser.fields // [{ name: 'id', tag: 1, type: 'uint32', repeated: false }, …]
// Canonical proto3 JSON (camelCase keys, RFC3339 timestamps, "1.5s" durations,
// enum names, base64 bytes, 64-bit as strings) - distinct from the binary shape.
const j = AcmeUser.toJson(user) // or toJson('acme.User', user)
const u = AcmeUser.fromJson(j) // or fromJson('acme.User', j)Encode/decode errors are thrown as typed ProtobufErrors, so you can branch on
kind instead of matching message strings:
import { ProtobufError } from '@klaappinc/react-native-nitro-protobuf'
try {
AcmeUser.encode(huge)
} catch (e) {
if (e instanceof ProtobufError && e.kind === 'limit-exceeded') {
console.warn(`${e.field} is too large`)
}
}kind is one of unknown-field, unsupported-field, limit-exceeded,
type-mismatch, unknown-message, decode, unknown; field and
messageName are populated when known.
Regenerate on every Metro start (pairs with proto:generate --watch for live
edits):
// metro.config.js
const { getDefaultConfig } = require('@react-native/metro-config')
const {
withNitroProtobuf,
} = require('@klaappinc/react-native-nitro-protobuf/metro')
module.exports = withNitroProtobuf(getDefaultConfig(__dirname), {
protoDir: 'proto',
})On-device (Hermes, Release), acme.User (~70 B payload), throughput in ops/sec:
| encode | decode | wire size | |
|---|---|---|---|
| react-native-nitro-protobuf | 0.18 M | 0.26 M | 70 B |
| protobuf.js | 0.07 M | 0.12 M | 70 B |
| JSON | 0.44 M | 0.57 M | 210 B |
vs protobuf.js: ~2-7× faster encode, ~2× faster decode (medium/large payloads). vs JSON: Hermes' native JSON is faster on raw CPU, but protobuf is ~3× smaller on the wire - choose it when bytes matter (network, storage, IPC). Full methodology and per-field-type numbers in PERFORMANCE.md.
encode and decode are synchronous JSI calls and must run on the JS thread
(the runtime that created the Protobuf HybridObject). Calling them from a
Reanimated worklet, a separate JS runtime, or any other thread is unsupported
and undefined behaviour - the most common cause of hard crashes. To
(de)serialize off the main JS thread, marshal the result back first.
Verified configurations (others likely work but are untested):
| Supported / tested | |
|---|---|
| React Native | 0.81 (Expo SDK 54) and 0.85 |
| Architecture | New Architecture only (TurboModules/Fabric); Old Arch unsupported |
| JS engine | Hermes |
react-native-nitro-modules |
0.35.x (peer dependency) |
| Expo | SDK 54 (config plugin + dev/prebuild) |
| Platforms | iOS 13+, Android (minSdk per RN) |
| protobuf syntax | proto3 + proto2 (no extensions/groups) |
| Node (codegen) | 18+ (plus python3 once, for the nanopb generator) |
This package follows semver:
- patch - fixes, perf, docs; no API or wire-format change.
- minor - backward-compatible additions (new codegen output, new options, new supported field types). Generated code stays source-compatible.
- major - breaking changes to the public TS API, the generated output shape, or the JS⇄proto value mapping.
Deprecations are announced in the changelog and kept for at least one minor
release before removal in the next major. The protobuf wire format is stable
(it is standard protobuf); upgrades never change bytes on the wire for a given
.proto. Releases use Conventional Commits
(see Releasing).
encode/decode throw on invalid input - they never silently truncate or
corrupt data. Encode validates against the schema and the field size limits:
| Condition | Behavior |
|---|---|
| Unknown field name | throws Unknown field "<name>" ... |
| Wrong JS type for a field | throws (e.g. expects number/string/array) |
string over max_length |
throws ... exceeds max_length ... |
bytes over max_size |
throws ... exceeds max_size ... |
repeated over max_count |
throws ... exceeds max_count ... |
| non-numeric 64-bit string | throws (must be a full decimal integer) |
Uint8Array for bytes |
throws (use base64 or number[]) |
| unknown fields on decode | skipped (standard proto3 forward-compat) |
Tune the limits per field in .options (see Field size limits).
- Only nanopb static fields are supported (sized via
.options/ defaults). - A single message must stay under 64 KB encoded (nanopb default;
PB_FIELD_32BITwould lift it - see ROADMAP). - The
Struct/Value/ListValue/Anywell-known types are not yet supported (they throw with a clear message).oneof,map, proto2 (optional/required/repeated/defaults; no extensions/groups), Timestamp, Duration, Empty, FieldMask and the scalar wrappers are supported. - 64-bit integers are represented as decimal strings.
Uint8Arrayis not accepted forbytes(use base64 ornumber[]).
See ROADMAP.md for what's planned.
.proto ──generate──▶ nanopb C structs (*.pb.{h,c}) + registry (message metadata)
│
JS object ◀──▶ Nitro AnyMap ◀──▶ ProtobufCodec (C++) ◀──▶ nanopb wire bytes
Your .proto is compiled to nanopb static C structures plus a generated registry
that maps message names to their descriptors. At runtime the codec converts a JS
object to a Nitro AnyMap, walks the registry to populate the nanopb struct, and
encodes it - and the reverse for decode.
- Android: "HybridObject 'Protobuf' has not been registered" - rebuild the app after installing/upgrading (the native library is loaded at startup).
python3 not foundduringproto:generate- install python3, or pass--nanopb <path to protoc-gen-nanopb>to use your own generator.- A field is missing after decode - regenerate (
npm run proto:generate) after changing.protofiles, then rebuild. - "Bytes fields must be base64 strings or number arrays" - don't pass a
Uint8Array; see the value-mapping table.
bun install
bun run test # unit tests + native ASan/UBSan fuzz harness (when toolchain present)
bun run ios # run the example appThe example/ app is a playground/round-trip demo; bench/ holds the benchmark
suite used for PERFORMANCE.md.
CI (.github/workflows/test.yml) runs typecheck, build, and the test suite
(including the native ASan/UBSan fuzz harness) on every push and pull request.
Publishing is automated by release.yml, which runs the full test suite and then
npm publish on every published GitHub Release:
- Bump
versioninpackage.json(Conventional Commits:feat:/fix:→ minor/patch) and land it onmain. - Create a GitHub Release with the matching tag (
vX.Y.Z):gh release create vX.Y.Z --target main --notes "…". release.ymlruns CI (lint + tests + ASan/UBSan fuzz), then publishes@klaappinc/react-native-nitro-protobufto npm.
A release-please workflow is also configured to draft the release PR automatically, but it needs the org setting “Allow GitHub Actions to create pull requests” enabled; until then, cut releases manually as above.
One-time setup: add an npm automation token (2FA-exempt) as the NPM_TOKEN
repository secret (Settings → Secrets and variables → Actions) with publish
rights to the @klaappinc scope.
This repo uses Git Flow: branch features off develop (the default branch)
and open PRs into develop; main is production and is released automatically.
See CONTRIBUTING.md for the full branch model and commit
conventions.
MIT © Klaapp Inc.
