Skip to content

Alternative runtime + distribution: scriptc-compiled single-binary relay (POC, load-test data, optimistic verification) #902

Open

Description

@espetro

Title: Alternative runtime + distribution: scriptc-compiled single-binary relay (POC, load-test data, optimistic verification)


Following up on #147 (alternative storage adapters): we explored the adjacent axis, alternative runtime + distribution, and have a working POC on our fork with measured data before proposing anything for the tree. The short version: the relay's protocol core compiles with vercel-labs/scriptc into a single ~5 MB static Linux binary with no Node.js runtime and no embedded JS engine, and a deferred (optimistic) signature-verification mode built on top of it reaches 3,908 events/s sustained on the bench box, 4.6x the stock relay in the same test, while using 9.4 MB of RSS instead of 2.3 GB.

What we built (POC, on our fork)

The work is a chain of focused PRs on espetro/nostream, each independently reviewable:

  • #1 answers Feature Request: alternative storage adapters #147's storage-adapter ask directly: a thin StorageDialect / CacheDriver seam (~400 LOC in-tree) with DB_ADAPTER / CACHE_ADAPTER module loading, so backend specifics can live in community-maintained packages. It ships a self-contained SQLite adapter (DB_CLIENT=sqlite + CACHE_DRIVER=sqlite, better-sqlite3, FTS5 for NIP-50, Redis-API-compatible cache shim including the broadcast stream and the two Lua rate-limit scripts) that runs the whole stock relay on one SQLite file with zero daemons. The Postgres path is verbatim upstream code.
  • #2 is the feasibility probe: nostream-validate, a NIP-01 event validator / filter matcher (vendored BIP-340 + SHA-256, no @noble) compiled to a ~460 KB static ELF, scriptc coverage 306/306, no dynamic remainder.
  • #3 is the actual relay: NIP-01 (EVENT/REQ/CLOSE/EOSE, replaceable + ephemeral events, NIP-33 upserts), filter matching, SQLite storage, WebSocket transport, in-process fanout, authored in the scriptc TS subset on @chttp/router + @chttp/ws + @cutils/sqlite, compiling to a 5.0 MB static ELF at 100% static coverage (2839/2839 statements).
  • #4 ports the HTTP surface (healthz, stats, NIP-11 document) onto the already-vendored @chttp/router inside the same binary, replacing express for this deployment shape.
  • #6 adds optimistic signature verification (its own section below), and #7 adds an optional sampled variant with a documented trust tradeoff, kept separate so the safe mode can be evaluated on its own.

Things deliberately left out of the compiled surface (knex migrations as operator tooling, pino, zod, worker_threads) are listed with reasons in native/relay/README.md on the fork.

Why scriptc

The stack stays TypeScript: scriptc compiles an ordinary TS subset to a small native executable, so the protocol code above is readable, type-checked TS, not a C rewrite. That mattered for feasibility: the whole NIP-01 core ported in days, not weeks, and scriptc coverage gives a checkable gate that a unit has no dynamic remainder (no hidden quickjs fallback), which is what makes the "single static binary" claim verifiable rather than aspirational. scriptc is developed under vercel-labs (Vercel's GitHub org), so the toolchain risk looks bounded. The dependencies it pushes you toward have familiar APIs: @chttp/router is a minimal HTTP router, @chttp/ws a WebSocket layer over it, @cutils/sqlite an embedded SQLite with a vendored amalgamation; all three are authored in the same compilable subset, which is why the binary stays static. Full disclosure: I am the maintainer of @chttp and @cutils, so weight that recommendation accordingly; the relay vendors their sources rather than taking them as runtime npm dependencies, so they are swappable. The subset has real limits (we hit and worked around a few: intersection types with no runtime shape, template-literal index signatures, strictNullChecks being required, so the fork scopes it to native/), and the README documents them honestly.

Load-test results (same box, 60k signed-event corpus, 4 sockets, 30s sustained)

config sustained ingest EVENT OK p50 cold start peak RSS artifact
stock relay, node/ts-node, sqlite mode (baseline) 858 ev/s 146 ms 9.7 s 2364 MB sources + node + node_modules
native ELF, sync verification 534 ev/s 236 ms 421 ms 9.3 MB 5.0 MB static ELF
native ELF, optimistic verification 3,908 ev/s 10.3 ms ~416 ms 9.4 MB 5.1 MB static ELF

Honest read: the native binary in sync mode is ~62% of stock throughput because it is a single OS thread doing signature verify + JSON parse + SQLite writes inline, while the stock relay fans storage out to worker threads and benefits from more warm JIT. In exchange you get one 5 MB deployable file, 254x less resident memory, and ~23x faster cold start. For the sub-1 GB VPS / embedded-operator segment that cannot run Postgres + Redis + Node (or does not want three processes to babysit), that trade looks favorable; for large public relays, stock is the right choice. REQ latencies are comparable between native and stock (p50 9.5 vs 7 ms for author queries, 29 vs 17 ms for single-char tag queries).

The SQLite adapter from #1 was benchmarked separately against Postgres + Redis on the same host: identical relay code, and the daemon-free mode lands at full query parity (ingest 644 vs 668 ev/s, REQ percentiles within noise or better) with ~2.7x less disk for the same 3k-event corpus. The bench also caught a real regression there: correlated tag-filter EXISTS probes that could not resolve in-index until the adapter gained a covering event_tags(event_id, tag_name, tag_value) index.

The bottleneck, and optimistic verification

Profiling the native relay's ingest path showed the obvious-in-retrospect thing: BIP-340 signature verification is ~94% of it. The same binary with verification skipped (bench aid only) sustains 9,081 ev/s, versus 534 verified. So the ceiling is not transport, JSON, or SQLite; it is schnorr, and it is paid inline before the store and before the OK frame in upstream semantics.

That motivated an optimistic verification mode (RELAY_VERIFY_MODE=optimistic, #6), and we think the idea is interesting independently of the native binary: it converts per-event latency into throughput by pipelining verification behind the ACK, the same way Postgres pipelines fsync behind synchronous_commit = off while never letting uncommitted data be read. The mechanics: an EVENT is stored with verified=0 and ACKed immediately; a bounded queue (4096, overflow degrades to inline sync verification, so backpressure is bounded) drains a few items after each inbound frame; REQ and fanout only ever touch verified=1 rows, so nothing invalid is ever served or broadcast; a failed check tombstones the event (verified=-1), NOTICEs the sender, and quarantines the pubkey, retracting its pending rows and forcing its future events to verify inline. Every event is still verified; adversarially the servable set is identical to sync mode, only OK ordering differs (an invalid event gets an OK first and a retraction after). On the bench that is 3,908 ev/s sustained with OK p50 10.3 ms, 7.3x the same binary in sync mode and 4.6x the stock baseline, at unchanged RSS and cold start. There is also a sampled mode (#7) that only verifies a percentage of traffic and reaches 7,536 ev/s at 25%, but it deliberately weakens the signature guarantee for unsampled events, so we stacked it separately and would only document it as an explicit tradeoff, never a default.

Worth stating plainly: optimistic verification does not make the single-threaded binary faster at verifying; it hides verification latency behind the ACK so ingest stops waiting on it. The Node relay could adopt the same pattern (its per-event cost profile is different, but the ACK-before-verify ordering and the verified=1 servability gate compose with worker-thread verification the same way).

Why this fits #147's frame, not a big diff

None of this asks the project to maintain a second runtime. The upstreamable surface is small: the storage/cache seam from #1 (which also serves the CockroachDB/MySQL/serverless interest already voiced in #147), and optionally the protocol-core carve-out the native work proved out. The scriptc relay itself lives entirely in a native/ subtree plus community packages and can stay a documented community distribution.

Questions for maintainers

  1. Would you accept the thin StorageDialect / CacheDriver seam (with DB_ADAPTER / CACHE_ADAPTER module loading) upstream, keeping SQLite and other backends community-maintained out-of-tree? That is roughly the smallest diff that unlocks alternative storage for Feature Request: alternative storage adapters #147.
  2. Is there interest in documenting a "community distributions" page, the way some projects list install variants, so a native-binary build can exist alongside docker/source without living in-tree?
  3. Is there appetite for optimistic verification (ACK-before-verify with a verified-only servability gate) as an upstream relay feature, independent of the native variant? We would propose it as its own issue + PR with the tradeoffs above.

Everything is public on the fork, and both benchmarks are reproducible from a clean checkout: native/relay/bench/run.sh (native vs stock, cold start + RSS + sustained ingest + percentiles) and benchmark/run.sh (Postgres+Redis vs SQLite adapter). Happy to PR whichever granularity is useful, or none of it, the data is the point.


(POC and measurements via Devin sessions, posted by @espetro.)

Activity

  1. cameri commented on Oct 19, 2026

    @cameri
    Owner

    @espetro Thank you for your issue and interest in Nostream.

    1. Yes. However, I would prefer if your proposed backends are in-tree. That said, I am not closed to the idea of community-maintained backends.
    2. Yes.
    3. Yes. Pessimistic, optimistic, and no verification as a setting a relay operator can change, preferably. If not technically feasible, an environment variable would be a good-enough fallback.

    Be advised that a Rust rewrite of Nostream is in the works.

    If you would like to be a collaborator of Nostream, please state your interest for the record.

  2. chappie-daemon commented on Oct 24, 2026

    @chappie-daemon
    Collaborator

    Decision (2026-10-24): its own track, not on the rewrite's critical path

    Labelled own-track and left without a milestone. It is a distribution and runtime experiment — a scriptc-compiled single binary with its own storage seam, NIP-01 relay, HTTP surface and a validator — carried on a fork, not a slice of the parity port in #860's phases.

    Stated plainly so nobody waits on it: this may fail without consequence for the rewrite. Nothing in Phases 1–7 depends on its outcome; if the POC's load-test data later argues for a different distribution shape, that is a Phase 7 cutover input at most, and the rewrite can ship a normal multi-crate binary.

    The useful part to keep regardless of the verdict is the comparison data the fork already produced, and the StorageDialect / CacheDriver seam in its first PR, which is the same question #864 answers differently — worth a read when that job is scoped rather than a dependency now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    own-trackStandalone track, outside the rewrite's critical path

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions