Home / Articles / TypeScript Without Node or V8: Benchmarking scriptc Native Binaries

This article is published in English.

TypeScript Without Node or V8: Benchmarking scriptc Native Binaries

Cold start, RSS, throughput, and CPU trade-offs when the same TypeScript HTTP server runs as a scriptc native binary instead of Node.js with V8.

975 words

Benchmark notes from running one TypeScript HTTP service as a native binary instead of under Node.js with V8.

Backend JavaScript almost always needs a host runtime. On Node, Deno, or Bun that host is typically Google’s V8: JIT compilation to machine code, garbage collection, and an event loop tuned for concurrency.

An alternate experiment asks whether TypeScript servers can skip Node, V8, and every JS engine, compiling instead to one self-contained native binary.

Vercel Labs’ experimental compiler scriptc pursues that path. TypeScript is lowered to LLVM IR or intermediate C, then finished by ordinary compilers such as clang.

The same TypeScript HTTP service was measured in two setups:

  1. Node.js v22.21.1 executing with built-in type stripping
  2. scriptc v0.0.32 producing a native ELF x86-64 binary

Results below summarize what changed after removing V8 from that server.

1. Workload under comparison

Fairness mattered, so the server uses Node’s stock http module with no extra dependencies. Source text, business logic, and HTTP boundaries stay identical across both hosts.

Routes covered:

  • /health — cheap readiness probe
  • /json — structured JSON body
  • /compute — tight numeric loop
  • /hash — SHA-256 via node:crypto
import { createServer } from "node:http";
import { createHash } from "node:crypto";

const server = createServer((req, res) => {
  const url = new URL(req.url ?? "/", "http://localhost");

  if (req.method === "GET" && url.pathname === "/health") {
    res.writeHead(200, { "content-type": "application/json" });
    return res.end(JSON.stringify({ status: "ok" }));
  }

  // Additional routes (/json, /compute, /hash)
});

server.listen(process.env.PORT || 3000);

2. Cold-start latency

Serverless platforms, edge functions, and containers that scale to zero care about process boot time. Timing ran from process spawn until /health answered successfully, repeated across ten trials.

  • Node.js averaged roughly 232.16 ms
  • scriptc averaged roughly 14.23 ms (about 16.3× quicker)

Skipping V8 boot, parse cost, and type-stripping setup lets the native binary answer almost immediately.

3. Idle RSS and virtual size

V8 reserves heap and JIT buffers up front. How much memory sits reserved while the process waits?

  • Physical RSS for Node hovered near 70.40 MB
  • Physical RSS for scriptc hovered near 2.55 MB (about 27.6× leaner)

Virtual size told a sharper story: Node showed around 21.5 GB VSZ from V8’s mapping strategy, while scriptc stayed near 4.09 MB.

4. Request throughput and latency

oha drove load for 10 seconds at 10, 100, and 500 concurrent clients.

Light probe (/health)

  • At 500 concurrency, scriptc delivered about 29,828 RPS against Node’s 9,966 RPS (~2.99×).
  • At 100 concurrency, p50 latency was about 2.78 ms for scriptc versus 8.19 ms for Node.

JSON responses (/json)

Serialization included, scriptc reached about 24,209 RPS versus Node’s 10,012 RPS at 100 concurrency (~2.42×).

5. CPU paths: ahead-of-time versus just-in-time

CPU-oriented routes expose where AOT and JIT diverge.

Arithmetic loop on /compute

The handler repeats (result + i * 31) % 1_000_000_007.

  • Node managed about 2,835 RPS (p50 near 27.38 ms)
  • scriptc managed about 2,620 RPS (p50 near 32.21 ms)

Here Node’s JIT won by roughly 15%. Inspecting C emitted by scriptc shows locals as C double values and modulo via fmod():

double sc_t11 = fmod(sc_t9, sc_t10);

The fmod() call pays dynamic-library cost. V8 observes integer-like behavior at runtime and can specialize toward native 32-bit integer division, which is cheaper.

SHA-256 on /hash

Hashing goes through node:crypto.

  • Node: about 9,873 RPS (p50 near 8.45 ms)
  • scriptc: about 28,849 RPS (p50 near 2.97 ms)

scriptc is roughly 2.9× ahead. Node crosses into C++ twice for .update() and .digest(), paying OpenSSL binding overhead each time. scriptc collapses the chain into one native call:

ScrStr *sc_t212 = scr_crypto_hash_digest_str(sc_t209, sc_t210, sc_t211);

Already running in native memory removes that JS↔native round trip.

6. Container image size

Packaging for Kubernetes-style deploys:

  • Images based on node:22-slim landed near 120 MB
  • Images based on debian:bookworm-slim carrying only the dynamically linked scriptc binary landed near 80 MB; static or scratch-style packaging can approach 25 MB

7. Hard limits of scriptc today

The compiler remains experimental and narrow:

Dynamic patterns: heavy use of any or highly dynamic JavaScript falls back to embedding QuickJS (~620KB) and loses AOT speed advantages.

Package ecosystem: many npm modules rely on Node internals or reflection that cannot be compiled statically.

Missing JIT: as /compute showed, runtime type specialization from a JIT is unavailable.

8. Practical takeaway

Stay on Node when:

  • Large npm dependency graphs are essential
  • Code leans dynamic or loosely typed
  • JIT help on dynamically typed numeric loops matters

Consider scriptc when:

  • Scale-to-zero or edge hosts need sub-15 ms starts and sub-3 MB cold RSS
  • The service mostly wraps native libraries (crypto, networking) as a thin API

Reproduction artifacts live in this GitHub repository.