Getting started
How to decide whether an
@amigo-labs/* package is right
for your use case, what every verdict means, and how to read the
benchmark numbers.
Install
Every package ships as a regular npm package with prebuilt binaries for the six supported platforms . No build step on install, no compiler required.
pnpm add @amigo-labs/<name>
# or: npm install @amigo-labs/<name> Browse the catalog on the homepage. Each package page has a manager-aware install snippet.
When NAPI is worth it
Every call from Node.js into a NAPI module pays a fixed cost just for crossing the boundary — even before any actual work happens. That floor is roughly 109 ns per call on modern Linux x64. If a JS function returns in less than that, native cannot beat it. The packages in this repo are picked specifically because their workload dwarfs the boundary.
- Hard floor109 ns
Every NAPI call.
noop()measured at 9.15M ops/s. - Strings cost ~0.35 ns/byte~35 µs / 100 KB
UTF-16 → UTF-8 conversion scales linearly.
- Buffers are free~180 ns
Flat at 1 KB or 10 MB — Buffers travel as V8 handles, not copies.
The full table — every measured primitive, exactly what the boundary costs, what flat-fee vs. per-byte means — is below.
Verdict rubric
Every shipped package gets one of these classifications based on measured speedup against its JS alternative. The pill on each package page shows where that crate currently sits.
- 🟢 Green At least 2× faster than the best JS alternative on medium/large inputs, never slower than 1× on the realistic minimum. Clear reason to exist.
- 🟡 Yellow Mixed results or only marginally faster. An optimization sprint decides upgrade to Green or downgrade to Red.
- 🔴 Red Loses on the realistic median against the JS alternative most of the time. Candidate for deprecation unless a rewrite produces a measurable turnaround.
- ⚫ Black Structurally the wrong call. NAPI cannot win the use-case — usually because the JS path is already too cheap.
Read the full framework on the perf-review page .
Node vs Browser
Most packages ship two builds in the same npm tarball: a
napi binary for Node.js and a
WASM build for browsers and bundlers.
Your bundler picks the right one automatically through the
package.json conditional
exports field — the import
specifier never changes. Node consumers get the prebuilt native
addon; browser consumers (Vite, esbuild, webpack ≥ 5,
Rollup) get the in-tarball wasm/pkg/
artifact.
Three packages are server-only
- argon2 — memory-hard hash; the inner blake2b loop runs ~2× slower in WASM, and browser password hashing is a security anti-pattern. Server-side only.
- jose — JWK keygen + signing. Private signing keys must not ship to the browser, so the WASM build is intentionally not produced.
-
jwt
— sign-and-verify in the same module; same exfiltration concern as
jose.
Performance: napi vs WASM vs JS
Measured during the WASM pilot on
@amigo-labs/slugify v0.1.0 —
slugify is representative of the catalog's "small inputs, hot loop"
shape. The table reads
ops/sec per build and the two
speedup ratios.
| Input | napi | WASM | JS | napi/WASM | WASM/JS |
|---|---|---|---|---|---|
| 20-char ASCII | 1.02 M | 0.63 M | 0.32 M | 1.61× | 1.95× |
| 500-char ASCII | 87 K | 50 K | 14 K | 1.75× | 3.46× |
| Unicode-heavy 100c | 0.22 M | 0.15 M | 0.06 M | 1.46× | 2.41× |
WASM is 1.5–1.8× slower than napi across these inputs, but still 2–3× faster than the JS competitor. Reach for napi when you control the runtime (server, scripts, build tooling); reach for WASM when you don't (browser, edge, sandboxed runtimes).
When you'd still pick WASM on a server
- Edge runtimes — Cloudflare Workers, Deno Deploy, and similar sandboxes don't load native addons. The WASM build is the only option.
- Missing prebuilt binary — if your platform triple isn't in the supported six, the install will still pull the WASM tarball entry; bundlers can fall back to it.
- Hermetic CI — some build pipelines forbid binary downloads. WASM is just bytes in the tarball, no optional-dependency dance.
Platform support
Prebuilt binaries for the six major NAPI targets — pulled automatically as optional dependencies during install.
- Linux x64 (glibc)
x86_64-unknown-linux-gnu - Linux x64 (musl)
x86_64-unknown-linux-musl - Linux ARM64
aarch64-unknown-linux-gnu - macOS x64
x86_64-apple-darwin - macOS ARM64
aarch64-apple-darwin - Windows x64
x86_64-pc-windows-msvc
Node.js ≥ 22 required. No native compilation toolchain needed on the consumer machine.
Troubleshooting
- "Cannot find module '@amigo-labs/<name>-linux-x64-gnu'"
-
Optional dependencies were skipped during install (frequent on
CI). Re-run install without the
--no-optionalflag, or setnpm_config_optional=truein the environment. - Unsupported platform / triple
- If your platform is not in the supported list, the install will fail loudly. Open an issue with the missing triple — we add platforms based on real demand.
- "GLIBC_X.YY not found" on older Linux
-
The glibc prebuild targets a recent baseline. Use the
muslvariant on Alpine/musl distros or self-host a custom build (see docs/RELEASING.md ).
FFI overhead — full baseline
FFI Overhead Baseline
What does an
@amigo-labs/*call cost before any actual work happens? These numbers are the reference point for every other perf discussion in this repo. A package that does less real work per call than this table shows cannot structurally beat the JS alternative — no matter how fast the Rust code itself is.
Measurement Setup
- Crate:
crates/_ffi-bench/(not published,publish = false) - Harness:
vitest bench(npm run benchinside the crate) - Release profile:
lto = true, codegen-units = 1, strip = "symbols", panic = "abort" - Node: v22.22.2 on linux/x64 (glibc)
All five primitives perform no actual work per call — they only measure the fixed costs produced by the N-API boundary.
Measurements
| Primitive | Ops/s | Per-Call | Interpretation |
|---|---|---|---|
noop() |
9.15 M | 109 ns | The hard floor. Every NAPI call pays this, period. |
echoString(s) → String, 10 B |
4.28 M | ~234 ns | +125 ns: two tiny UTF-16/UTF-8 conversions. |
echoString 1 KB |
1.28 M | ~780 ns | +670 ns ≈ 0.6 ns/byte of extra cost. |
echoString 100 KB |
28.8 k | ~34.7 µs | ~0.35 ns/byte, scales essentially linearly. |
echoBuffer(b) → Buffer, 1 KB |
5.56 M | ~180 ns | Only +70 ns on top of noop. |
echoBuffer 100 KB |
5.75 M | ~174 ns | Flat. |
echoBuffer 10 MB |
5.58 M | ~179 ns | Flat even at 10 MB — a Buffer is a V8 handle, not a memcpy. |
sumArray(xs: Vec<u32>), 10 elements |
1.44 M | ~694 ns | ~58 ns per u32 on top of the fixed costs. |
sumArray 1000 elements |
23.0 k | ~43.4 µs | ~43 ns per u32 for array marshalling. |
sumArray 100,000 elements |
233 | ~4.29 ms | ~43 ns per u32 — scales linearly. |
What This Means
1. The Floor is 109 ns
For every package in the repo: a Rust function that gets called
and returns a result costs at least 109 ns. If the JS alternative
needs < 109 ns for the same input — for example because it only
operates on a precomputed buffer — Rust has no chance. With
nanoid that was exactly the finding: nanoid@5 takes ~260 ns per
call; a Rust binding cost ~1500 ns (see Phase B below for the
measurement). That's why nanoid was switched to pure JS.
2. Strings Cost About 35 µs per 100 KB
Every fn foo(s: String) -> String pays the UTF-16 ↔ UTF-8
conversion at both ends of the FFI. For large texts that eats up
enough time that any algorithm doing less than ~0.5 ns/byte of real
compute is overtaken by the conversion itself. Observation:
encoding's UTF-8-encode-10MB was 2.1× slower than iconv-lite
before the fix, because we were sending 10 MB through the FFI
converter twice (input + output) with an extra .into_owned() on
top.
Rule of thumb: If the Rust code does less than ~1 ns per byte of real compute, either
- replace the string input with a
Bufferinput (zero-copy, the caller owns the bytes), or - rewrite the package in pure JS (like
nanoid), or - don't port it in the first place.
3. Buffers Are Essentially Flat — That's the Fast Lane
echoBuffer is flat at ~180 ns from 1 KB to 10 MB. That's the
decisive difference: N-API buffers are V8 handles that only pass a
reference back and forth when crossing — no copy. 10 MB costs
exactly as much as 1 KB: 180 ns.
Consequence for every new package: bytes-in-bytes-out is always
the cheapest path. If the output of an algorithm is a binary blob
(hash, compressed data, image pixels, UTF-8 bytes), return it as a
Buffer, never as a String or Vec<u8>.
4. Vec<T> Arrays Are Expensive — 43 ns per Element
sumArray(Vec<u32>) costs ~43 ns per element. An array of 1000
u32s eats 43 µs of pure marshalling — the same data volume passed
in as a Buffer costs 180 ns. Factor 240× more expensive.
Consequence: if a package function processes a list of numbers or
bytes, it should accept a Buffer or Uint8Array, never Vec<T>
of primitives. For u16/u32/f64 use the corresponding TypedArray.
Example from the repo: xxhash batch 1000 × 64 bytes was 4.8 to
5.7× slower than xxhash-wasm. Hypothesis (to be verified): the
batch API returns hashes as Vec<BigInt>. That's 1000 BigInt
constructions + array marshalling = a large chunk of the runtime.
A returned Buffer (1000 × 8 bytes = 8 KB) would be ~180 ns flat.
5. What Do You Get "Back" for These Fixed Costs?
For a Rust port to pay off, the difference between (Rust work + FFI overhead) and (JS work) has to become significant. Rule of thumb:
- JS work < 1 µs per call → a Rust port only pays off with a batch API or if the Rust algorithm is dramatically (10×+) faster.
- JS work 1–10 µs → a 2× speedup is realistic if the Rust algorithm is measurably faster and the FFI has no Vec-marshalling trap.
- JS work > 10 µs → FFI overhead is under 10%, the full Rust win comes through.
These numbers depend on your hardware + Node version, but the orders of magnitude stay stable. Update them when the toolchain changes (Node major bump, V8 major bump, napi-rs major bump).
Reproducing
cd /home/user/amigo-native
# Build the bench binary (only needed once per toolchain change)
cd crates/_ffi-bench && npx napi build --platform --release
# Run the benchmarks
npm run bench