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

Concurrency

Fairyfly gives you three layers. Picking the wrong one is the usual cause of a server that "gets stuck".

LayerRuns onUse forLimit
JS event loopone threadeverything you writesee Event loop
Background I/Ointernal threadsfetch, Postgres, TLS, fs.*Async, ffi callsper-subsystem pools below — not one shared pool
Workersup to 8 OS threadsCPU-bound JS8 workers, 32 MB heap each, 4 MB per message

There is no single "4-thread pool" doing everything. Each subsystem owns its threads:

SubsystemThreadsCap / notes
Event-loop pool (libxev)4general loop work
fs.*Asyncown 4-thread pool + 16 job slotsa 17th concurrent job waits
ffiown 4-thread pooleach native call parks a pool thread while it runs
fetch16 dedicated threads (1 MB stack each)one thread per slot — a 16-way fan-out truly runs in parallel
Postgres / TLSnoneevent-driven on the loop thread; no helper threads
Worker8 OS threads (8 MB stack each)one JS runtime + 32 MB heap each; 4 MB per message

JavaScript itself never runs in parallel with other JavaScript. Two JS sections execute one after the other, always.

Decision table

You need to…Do thisNot this
Call other APIsawait fetch(...)blocking the loop
Query Postgres / SQLiteawait sql\…`/db.row()`spinning up a thread
Read/write files in a serverawait fs.readFileAsync(...)fs.readFile(...)
Hash, compress, parse a huge payloadnew Worker(...)a tight loop in the handler
Run 5 requests at oncePromise.all([...])sequential awaits

Example: parallel fan-out

// fanout.js — ff fanout.js
const urls = [
  "https://example.com/",
  "https://example.org/",
  "https://example.net/",
];

const started = performance.now();
const results = await Promise.all(urls.map((u) => fetch(u)));
console.log(`${results.length} responses in ${(performance.now() - started).toFixed(1)}ms`);
console.log(results.map((r) => r.status).join(", "));

All three requests are in flight at once — each fetch slot has its own thread, so a fan-out like this costs one loop, not three.

Top-level await works in files (modules). It does not work in ff -e '...', which is evaluated as a script — wrap it in an async IIFE there, or put the code in a file.

Example: non-blocking file I/O in a handler

// The async variants return Promises and run on the background pool.
http.serve({ port: 3000 }, async (url) => {
  const body = await fs.readFileAsync("index.html", "utf8");
  return new Response(body, { headers: { "content-type": "text/html" } });
});

Sync calls block the loop thread — fine for a CLI, wrong inside a server:

// Blocks every other connection while it runs:
const body = fs.readFile("index.html");

Example: CPU-bound work → Worker

// main.js
const w = new Worker("./hash-worker.js", { data: { rounds: 1e6 } });
w.onmessage = (e) => console.log("hash:", e.data.hex);
w.onerror = (e) => console.error("failed:", e.message);
w.postMessage({ password: "hunter2" });

Workers have their own JS runtime and event loop. They share no state and talk only by messages (structured clone). The worker scope has console, timers, postMessage, workerData, and performance — no fs, no fetch, no http.serve.

Limits worth memorising

ResourceCap
Concurrent fetch16 slots, one thread each
Concurrent HTTP connections512
Loop / fs / ffi pools4 threads each (separate pools)
Concurrent fs.*Async jobs16
Workers8
Worker message4 MB
Live timers128

Gotchas

  • Promise.all fails fast — one rejection rejects the whole batch. Use Promise.allSettled when every result matters.
  • Sync fs, a long while loop, or a big synchronous parse delays every pending timer and connection.
  • Workers never exit on their own; the process waits for terminate().
  • Worker files are classic scripts — import/export inside a worker throws.

See also