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:
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:
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:
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:
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:
- 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.
- Drain subsystems in a fixed order: fetch → WebSocket client → FFI → async
fs→ workers. - Pump microtasks queued during the turn.
- Check GC every 8192 turns, and only when the heap exceeds 8 MB.
- Exit if nothing relevant is pending; otherwise sleep 100 µs, doubling up to 8 ms.
That ordering produces predictable behavior:
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:
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:
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:
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
.jsfirst. - 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 needffi.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-shapedbind,connect,sendto,recvfrom,send,recv, andclose. Up to 64 sockets; binary payloads; IP literals only. child_process—spawn,exec, andexecSyncwith capturedstdout/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
fetchand WebSocket —HTTP_PROXY,HTTPS_PROXY,ALL_PROXY, andNO_PROXY, includingCONNECTtunneling for HTTPS/WSS. No SOCKS support. - Direct package sources for
ff imprint—github:owner/repo,codeberg:owner/repo, forge HTTPS URLs, and.tar.gz/.tgzarchives.ff.lockrecords 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/Responsebodies are NUL-terminated for QuickJS JSON parsing in every path, fixing intermittentunexpected data at the endfailures. - 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)
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:
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
From there:
- Quickstart for the first server
- HTTP Server for routing and JSON APIs
- Architecture for the full layering
- Event loop and Concurrency
- Limitations for every cap in one place
- CLI for every
ffsubcommand
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.
