Serverless-first. Built for cold starts, tight memory, and one binary.Deploy guide →

What is Fairyfly? How it works and why it works that way

v0.2.0-beta · Design deep dive — published 2026-10-29

Fairyfly is a serverless-first backend JavaScript runtime built with Zig and powered by QuickJS. It is not a Node.js clone and it is not a browser engine. It is a small, single-binary runtime with a deliberately narrow surface: HTTP, WebSocket, fetch, SQLite, Postgres, UDP, workers, files, crypto, subprocesses, and the process APIs a backend actually needs.

Serverless is the primary target, not an afterthought: short-lived processes, fast cold starts, tight memory budgets, and handlers that do one thing.

This post explains what the runtime is, how a request moves through it, and why the implementation makes the trade-offs it does. It is written for the current release, v0.2.0-beta — the first beta since v0.1.0-canary, landed as 35 non-merge commits across 110 files. You can read the full release notes alongside this article.

What ships in one binary

There is no plugin system and no package to install for the core. A single ff binary exposes:

AreaWhat you get
HTTPHTTP/1.1 server, TLS, HTTP/2 over ALPN, automatic WebSocket upgrades
Clientsfetch, a WebSocket client, and UDP sockets
DataSQLite (Database) and Postgres (sql / SQL)
WorkWorkers, child_process, timers, microtasks
SystemSync + async fs, crypto, URL, Blob, FormData, text encoding, FFI
Toolingff init, start, test, repl, compile, imprint, sever, fmt, upgrade, --watch

The language surface is intentionally small too. Files are ES modules only: there is no require(), no CommonJS, and no browser DOM. Packages are pure-JS ESM installed by ff imprint into one flat node_modules.

That is the first design decision: the runtime is a backend runtime, not a compatibility layer for every JavaScript environment.

Serverless is the target

A serverless function is usually a short-lived process with a cold start, a memory budget, and a handler that does one thing. Fairyfly treats those constraints as requirements:

Serverless constraintDesign choice
Cold startOne binary, no runtime dependency tree, embedded QuickJS (~10 ms inside-VM cold start)
Memory budgetAbout 5 MB RSS on Apple silicon (10–16 MB in Linux microVM benchmarks)
Short-lived processThe event loop exits when no referenced work remains
One handlerBuilt-in HTTP server, fetch, SQLite, Postgres, and WebSocket
Predictable cost512 connections, 10 MB response cap, 128 timers, 8 workers
No install at bootCore features ship in the binary

The runtime is a native POSIX binary, not a V8 isolate. It fits container-based serverless platforms and edge VMs that can run a Linux binary — Cloud Run, Fly.io, and other container-based serverless platforms — rather than platforms that only accept JavaScript isolates such as Cloudflare Workers. The deploy loop is the same everywhere: put the binary and your ff.json in the image, start with ff start, and let the platform provide PORT.

That is why the limits later in this post matter. A fixed connection table and fixed response buffers are not accidental restrictions; they are how a cold process knows its memory footprint before it serves the first request.

Four layers, no plugin boundary

The codebase is organized as a straight line from JavaScript to the operating system:

┌──────────────────────────────────────────────────┐
│ JavaScript source (.js files)                    │
└──────────────────┬───────────────────────────────┘
                   │ QuickJS parser
┌──────────────────▼───────────────────────────────┐
│ QuickJS bytecode + JS runtime (GC, value stack)  │
└──────────────────┬───────────────────────────────┘
                   │ C ABI (src/c.zig → quickjs_shim.zig)
┌──────────────────▼───────────────────────────────┐
│ Zig API layer                                    │
│   api/     console, fs, process, crypto, url,    │
│            fetch, websocket, sqlite, sql, ffi    │
│   net/     http_native (512-slot server),        │
│            http2_server, pg_client, async_fetch  │
│   event/   loop, timers, microtasks              │
│   types/   Headers, Request, Response, Blob      │
│   worker/  worker, message_port, serialize       │
│   commands/ CLI subcommands                      │
└──────────────────┬───────────────────────────────┘
                   │
┌──────────────────▼───────────────────────────────┐
│ libxev event loop (io_uring / epoll / kqueue)    │
└──────────────────────────────────────────────────┘

QuickJS parses and runs the JavaScript. The Zig layer implements every global and talks to the OS. A small C ABI shim connects the two. The event loop is libxev: kqueue on macOS, io_uring by default on Linux, with an epoll fallback.

The runtime vendors its native dependencies instead of delegating to a platform package manager:

ComponentVersionRole
quickjs-ng0.16.2JavaScript engine
BearSSL0.6TLS 1.2 for HTTPS/WSS
SQLite3.53.4The Database global
nghttp21.70.0HTTP/2 framing and HPACK

Why this shape? A direct API layer means a JavaScript fetch call is a Zig call, not a chain of adapters. It keeps the implementation auditable, makes the memory cost visible, and avoids a native addon boundary. The trade-off is that there is no ecosystem of third-party runtime plugins.

One thread, one loop

Every Fairyfly script runs on one OS thread. Concurrency comes from the event loop, not from running JavaScript in parallel.

One turn of the loop is deliberately ordered:

  1. Block for completions if referenced work is armed (a timer, socket, fetch, query, and so on). If only queued submissions remain, drain them without blocking.
  2. Drain subsystems in a fixed order: fetch → WebSocket client → FFI → async fs → workers.
  3. Pump microtasks queued during the turn.
  4. Check GC every 8192 turns, and only when the heap exceeds 8 MB.
  5. Exit if nothing relevant is pending; otherwise sleep 100 µs, doubling up to 8 ms.

That ordering produces predictable behavior:

console.log("1: sync");

queueMicrotask(() => console.log("3: microtask"));

setTimeout(() => console.log("4: timer 0ms"), 0);
setTimeout(() => console.log("5: timer 20ms"), 20);

console.log("2: sync");
1: sync
2: sync
3: microtask
4: timer 0ms
5: timer 20ms

All synchronous JavaScript finishes first. Microtasks run before the next timer or I/O callback. setTimeout(fn, 0) runs on the next loop turn, which makes it the closest equivalent to Node's setImmediate. Negative delays are clamped to zero; they defer, they never drop.

An async HTTP handler parks its connection instead of blocking the thread. While its Promise is pending, other connections keep flowing. If the Promise never settles, the request is reaped with 504 after 30 seconds. A throw or rejection becomes a 500.

Why one thread? It removes data races from JavaScript entirely and keeps ordering understandable. The cost is that a long synchronous loop blocks everything. That is why CPU-bound work belongs in a Worker, not in a request handler. The event loop cannot interrupt running JavaScript.

The HTTP hot path

The server is where the design is most visible. It uses a 512-slot structure-of-arrays connection table stored as parallel arrays, with connection flags packed into a 2-byte ConnFlags struct. The hot loop touches cache lines, not per-connection objects.

Each slot owns a 64 KB response staging buffer. Responses that fit are copied into that buffer; larger bodies spill to the heap, capped at 10 MB. Spills up to 256 KB are retained for reuse instead of being reallocated.

Other hot-path choices:

ChoiceWhy
512-slot SoA connection tableCache-friendly access and fixed memory
Packed 2-byte connection flagsFewer cache lines touched per request
64 KB static response stagingCommon responses avoid per-request allocation
Refcounted HeadersDataShare headers across Request / Response / Headers and threads without copying
Zero-copy URL slicesOne allocation per URL; components are pooled slices
Per-job Postgres arenasA warm query path allocates nothing; 32 KB retain cap plus a freelist

Why fixed buffers? They make memory behavior predictable. A server that allocates per request pays allocator and GC costs on every request. Fairyfly pays them once per slot and reuses the space. The trade-off is visible in the limits: request bodies share a 4 KB read buffer, response bodies are capped at 10 MB, and response headers are capped at 2048 bytes.

Concurrency without one shared pool

There is no single global thread pool. Each subsystem owns the threads it needs, which prevents one slow workload from starving another:

SubsystemThreadsNotes
JavaScript / event loop1All JS and Promise settlements
libxev loop pool4General loop work
fs.*Async4 + 16 job slotsA 17th concurrent job waits
ffi4Each native call parks a pool thread
fetch16 (1 MB stack each)One thread per slot; a 16-way fan-out runs in parallel
Postgres / TLS0Event-driven on the loop thread
Workersup to 8 (8 MB stack each)One JS runtime and 32 MB heap each

JavaScript itself never runs in parallel with other JavaScript. Workers are separate runtimes with their own event loops, 32 MB heaps, and structured-clone messaging (4 MB per message). Worker files are classic scripts, and worker scope has no fs, fetch, or http.serve.

Why separate pools? Different work has different latency and stack needs. A blocked fetch slot should not stop file I/O, and a slow native FFI call should not stop the event loop. The cost is a larger fixed thread footprint and more explicit limits.

The limits are the design

Fairyfly documents every hard cap because the caps are what make the runtime predictable. The important ones:

ResourceCap
Concurrent HTTP connections512
Request body (headers + body)~4 KB
Response body10 MB
Response headers2048 bytes
Concurrent fetch16
WebSocket clients64
UDP sockets64
Live timers128
Workers8
Concurrent child_process children8
Postgres pool10 connections
Sync fs read10 MB
ff compile input20 MB
TLSTLS 1.2 (BearSSL)
Hung HTTP handler30 s, then 504

Why so many caps? A fixed budget is easier to reason about than an unbounded one. The server knows its maximum connection memory. The fetch client knows its maximum concurrency. The database client knows its pool size. You can size a container from the documentation instead of discovering the limits in production.

The other side of that choice is intentional incompatibility:

  • No require(), CommonJS, Buffer, or DOM.
  • No TypeScript in the runtime; compile to .js first.
  • No Windows builds; the supported platforms are macOS and Linux.
  • Outbound clients are HTTP/1.1 only; HTTP/2 is server-side over TLS + ALPN.
  • No SOCKS proxies, no TLS 1.3 in the default BearSSL build.
  • Packages must be pure-JS ESM; build scripts and node: imports are rejected.
  • FFI requires --allow-ffi, and the prebuilt release binaries are built without FFI (-Dffi=false). Use a source build if you need ffi.dlopen.

If you need full Node compatibility, the npm ecosystem, browser APIs, or Windows, use Node, Bun, or Deno. If you need a small, serverless-first backend runtime with a fixed budget, Fairyfly is designed for that case.

What changed in v0.2.0-beta

The current release rounded out the backend primitive set and hardened the internals:

  • UDP sockets — udp.socket("inet" | "inet6") with POSIX-shaped bind, connect, sendto, recvfrom, send, recv, and close. Up to 64 sockets; binary payloads; IP literals only.
  • child_process — spawn, exec, and execSync with captured stdout / stderr, up to 8 concurrent children, POSIX only. Subprocesses are evented on the main loop, so they run alongside servers and fetches.
  • HTTP proxies for fetch and WebSocket — HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and NO_PROXY, including CONNECT tunneling for HTTPS/WSS. No SOCKS support.
  • Direct package sources for ff imprint — github:owner/repo, codeberg:owner/repo, forge HTTPS URLs, and .tar.gz / .tgz archives. ff.lock records the normalized source and a SHA-512 integrity hash.
  • CLI polish — consistent colors, ✓ / → / ✗ status output, aligned help, and consistent error text across commands.
  • JSON correctness — Request / Response bodies are NUL-terminated for QuickJS JSON parsing in every path, fixing intermittent unexpected data at the end failures.
  • Hot-path work — SIMD header lookup and lowercasing, a libxev fork fixing kqueue UDP IPv6 address truncation, a semver overflow guard, and DWARF-stripped release binaries.
  • Testing and tooling — seed-driven fuzzers for WebSocket frames, HTTP/1.1 headers, semver, TLS PEM, UTF-8, and HTTP/2; kcov coverage; a nightly fuzz workflow.
  • Docs — the documentation set grew from 29 to 46 pages, including the new UDP, child_process, console, crypto, process, request/response, text encoding, URL, WebSocket, and Worker references.

The release is still a beta. ff upgrade follows GitHub's latest release; releases marked pre-release are not picked up automatically.

Performance, in context

Serverless workloads demand both instant wake-up on cold starts and resilient throughput under high concurrent load. To evaluate both honestly, we benchmarked four runtimes inside isolated Linux microVMs configured with 2 vCPUs and 1 GB RAM:

Phase 1 — MicroVM Cold Start (First Request Wake)

RuntimeTotal E2ETTFB (Wake)Host TaxInside-VM App Cold Start
Fairyfly36.14 ms26.41 ms16.0 ms~10.4 ms ⚡
Deno98.54 ms82.43 ms3.0 ms79.4 ms
Bun118.51 ms82.55 ms2.0 ms80.6 ms
Node183.54 ms121.56 ms3.0 ms118.6 ms

Inside the microVM, Fairyfly initializes QuickJS and binds the HTTP port in ~10.4 ms — roughly 8x faster than Bun and Deno, and 11x faster than Node.

Phase 2 — Warm High-Concurrency (5M Requests @ 500 Concurrency)

Tested via oha across 5,000,000 requests at 500 concurrency:

RuntimeWarm RPSAvg Latencyp50p99Max OutlierSuccess Rate
Fairyfly164,967 /s3.02 ms2.93 ms5.01 ms25.5 ms100% (5M / 5M)
Bun163,610 /s3.04 ms2.94 ms5.29 ms21.8 ms100% (5M / 5M)
Deno163,566 /s3.04 ms2.93 ms5.27 ms117.2 ms100% (5M / 5M)
Node15,523 /s32.19 ms32.33 ms34.17 ms7,982.9 ms100% (5M / 5M)

Under 500 concurrency on 1 GB RAM, Node collapses to 15.5k RPS with an 8-second tail spike. Fairyfly sustains 164.9k RPS right inside its 512-slot connection table with an ultra-tight p99 of 5.01 ms and zero dropped requests.

Read the complete Serverless MicroVM Benchmark report for telemetry charts and methodology.

Try it

curl -fsSL https://www.fairyfly.dev/install.sh | sh

ff -e 'console.log("hello from fairyfly")'

ff init -y demo
cd demo
ff start

From there:

Fairyfly is small because it refuses to be everything. The four layers, the single event loop, the fixed hot-path buffers, and the documented limits are all the same decision: keep the serverless backend surface narrow, keep the memory predictable, and keep the codebase small enough to read.