Pumped Up Ticks
Pumped Up Ticks is Cruft's event loop, the scheduler that decides what runs next once the current stack unwinds. This page details its three job queues (next-tick, microtasks, macrotasks), the exact drain order, how async/await suspends and resumes, how timers and host I/O feed in, shutdown, and how a compartment puts a ceiling on the loop.
Pumped Up Ticks is the runtime's event loop, the scheduler at the center of Cruft Core. Every asynchronous thing your program does, a resolved promise, a fired timer, an arriving socket byte, a process.nextTick, an await that comes back, is scheduled here. It is the piece that decides what runs next once the current stack unwinds, and it is owned in the runtime rather than borrowed from an outside library. This page is the exhaustive account of how it works: the queues, the drain order, where async/await plugs in, how host I/O re-enters, how the process winds down, and how an agent compartment puts a ceiling on the whole thing.
Three queues and a seam
The loop is built on three first-in-first-out job queues plus one hook back into the host:
process.nextTick callbacks. A Node-shaped lane, drained to empty ahead of every microtask.queueMicrotask, async/await continuations. The standard microtask lane.The driver is a single function, run_to_completion, that walks these in a strict three-phase cycle. One pass through the cycle is one turn:
- Phase 1Drain the next-tick queue to empty, then run one microtask. Repeat until both next-tick and microtasks are empty.
- Phase 2Run exactly one macrotask, then start a new turn.
- Phase 3 · idleOnly when all three queues are empty, ask the host
poll_iohook for I/O. If it made progress, loop; otherwise the loop is done.
Phase 1 is the important one. Before each microtask, the entire next-tick queue is drained to quiescence, then one microtask runs, and the inner loop repeats. So next-tick callbacks always run ahead of promise reactions, and the full microtask checkpoint always completes before a single macrotask runs. This is the ordering real Node packages depend on, and it is reproduced exactly.
The order, worked out
Given this program:
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
process.nextTick(() => console.log("nextTick"));
queueMicrotask(() => console.log("queueMicrotask"));
console.log("sync");
the output is:
sync
nextTick
promise
queueMicrotask
timeout
sync runs first (it is on the current stack). Then the current stack unwinds and Phase 1 begins: the next-tick queue drains first (nextTick), then the microtasks in enqueue order (promise, then queueMicrotask). Only once the microtask checkpoint is completely empty does Phase 2 run the one macrotask the timer left behind (timeout).
Two rules cover almost every ordering question:
- Next-tick beats every other microtask.
process.nextTickandqueueMicrotaskland in different lanes; next-tick drains to empty before each microtask. - The microtask checkpoint fully drains between macrotasks. After any timer or I/O callback runs, every promise reaction it scheduled runs before the next timer or I/O callback.
Where async/await plugs in
An async function is real, resumable machinery, not a synchronous shortcut. When a compiled async function hits await, the Await operation unwinds the interpreter with a frame snapshot, capturing the locals, the operand stack, the program counter, the try stack, and the async-context, and hands that snapshot to the async driver. The driver parks the frame on the awaited promise and attaches a continuation. When the promise settles, that continuation runs as a microtask and re-enters the frame exactly where it left off.
Three consequences fall out of this design, and all three are load-bearing:
awaitalways yields at least one microtask. Evenawait 5or an already-fulfilled promise resumes through an enqueued continuation, never inline. This is the modern spec behavior (one tick per await), and it is unconditional.- **A rejection is thrown back at the suspension point.** When an awaited promise rejects, the reason is injected into the parked frame at the
await, and the frame's owntrystack is unwound to a livecatch. A localtry/catcharound theawaitsees the error first, before the async function's own result promise ever learns of it, because the frame is still intact to throw into. - The async-context travels with the continuation. The snapshot stamps the
AsyncLocalStorageframe that was active at theawait, so agetStore()after the await reads the store from the await point, not from wherever the resumption happened to fire.
Dynamic import() settles through the same discipline: a waiter registers against the target module's URL and resolves when the module reaches its evaluated state, including modules that themselves used top-level await, which settle in deterministic module-completion order.
Promises feed the microtask lane
Resolving or rejecting a promise never runs its handlers synchronously. It records the result, takes the promise's reaction list, and enqueues one microtask per reaction; the handler bodies run later, in queue order, when Phase 1 drains. then, catch, finally, and the combinators (all, race, allSettled, any, withResolvers) are all built on promise capabilities, a promise paired with its resolve/reject functions, so the plumbing schedules reactions without ever losing promise identity. Every promise reaction, every thenable adoption, and every async continuation targets the microtask lane; none of them touch next-tick.
A rejection with no handler attached is remembered, and reported at shutdown (see below). Attaching a handler later clears it.
Timers are the macrotask source
setTimeout, setInterval, and setImmediate register a timer against an absolute deadline. They do not fire themselves; the host's idle phase (Phase 3) collects every timer whose deadline has passed and enqueues one macrotask per fired callback. So a timer callback runs at a macrotask boundary, and the microtask checkpoint drains fully after it, exactly like Node.
queueMicrotask, by contrast, is a true microtask, and runs in Phase 1 ahead of any timer.
A few behaviors here are deliberately simpler than Node's, and worth knowing:
setImmediateissetTimeout(cb, 0). There is no separate "check" phase. AsetImmediateand asetTimeout(…, 0)fire in the order they were registered, not with Node's immediates-after-timers guarantee.- Timers due in the same drain fire in registration order, not soonest-deadline-first. Two timers that come due together run in call order.
ref/unrefare no-ops. A timer keeps the loop alive as long as it is registered; there is currently no way to register a timer that lets the process exit while it is still pending.
The full timer surface, including node:timers, timers/promises, AbortSignal support, and the scheduler namespace, lives on the runtime's timer module.
The host at the seam
Phase 3 is where OS I/O re-enters the language. When the three queues are empty, the loop calls a single host hook, poll_io, whose job is to turn readiness into enqueued macrotasks. The hook returns true (there is more to do, loop again) or false (nothing is pending anywhere, the loop is done).
Inside the hook, completed filesystem operations are converted to macrotasks first. If there are none, the hook walks a fixed ladder of I/O sources, returning at the first one that makes progress:
process.on('SIGINT', …)) — the most latency-sensitive, checked first.setTimeout/setInterval/setImmediate callbacks.fetch/HTTP-client round-trips, child-process output, datagrams, IPC, HTTP/2, socket data.The harvest stages sit ahead of the server stages on purpose: a live server's keep-alive would otherwise starve a co-hosted client (a fetch to your own in-process server, say), so completed round-trips are always drained first.
The "block on I/O" step is a cooperative poll-sleep, not a kernel readiness wait. When nothing is immediately ready but work is still outstanding, the hook sleeps until the nearest timer or watcher deadline, capped at one second and floored at one millisecond, then polls again. Actual socket and child readiness is produced by separate accept and worker threads that push into channels; the hook drains those channels each pass. This is a real, working scheduler, its latency floor is the poll cadence rather than an epoll/kqueue wakeup.
Two of the waiting paths are sharper than a fixed sleep. When the only thing outstanding is a native (N-API) completion or a pending network client, the hook parks on a condition variable that the producing thread signals on real readiness, with the one-second sleep as a fallback bound rather than the wake mechanism. So those waits wake on the event instead of on the next poll tick. The sleep-until-deadline cadence still governs the other idle branches, and none of this is an OS readiness wait: there is still no unified epoll/kqueue.
Winding down
When run_to_completion returns, the process does not exit immediately. The shutdown sequence, run by the host, is:
- Unhandled rejections are reported. Any promise that rejected without a handler is surfaced. With a
process.on('unhandledRejection', …)listener, it is delivered there; otherwise the disposition follows--unhandled-rejections(the default diagnoses and exits non-zero;warndiagnoses and continues;none/silentstay quiet). beforeExitre-drains. ThebeforeExitevent fires, and if a listener ran and scheduled more timer work, the loop runs again andbeforeExitre-fires. This repeats up to a bounded number of rounds, so a listener that reschedules forever cannot wedge shutdown.exitfires once, synchronously, on the way out. A listener may still adjustprocess.exitCode, so the final code is re-read afterward.- The exit code is truncated to a byte, matching Node (
process.exit(259)leaves 3).
process.exit() and an uncaught exception with a handler are earlier, terminal paths: they emit exit and return without re-entering the loop.
Bounding the loop
This loop is what lets Cruft run untrusted agents, so it is bounded at three different granularities, each checked in a different place. This is the machinery behind the agent compartment's availability guarantees.
--max-microtasks; overrun aborts the offending job before it runs.Two properties make these real ceilings rather than suggestions. First, the step budget and the wall timeout are uncatchable inside tenant code: they unwind past every try/catch/finally to the compartment boundary, where the host (not the tenant) decides what to do. Second, JIT entry is refused while a step budget or timeout is armed, because a compiled loop carries no interpreter safepoint to interrupt, so budgeted code stays interpreted and stays preemptible. The wall-timeout contract and its edges are documented on the security model and compartments pages.
The microtask budget is the one an agent author reaches for most: it is a hard cap on how much promise churn a single run may do. It counts microtasks only, not next-tick callbacks or macrotasks, and it is a lifetime total for the run, not a per-turn allowance.
Diagnostics
The loop is heavily instrumented, all of it off by default and gated behind environment variables. The ones worth knowing:
| Variable | What it surfaces |
|---|---|
CRUFT_EVENT_LOOP_COUNTERS | Per-phase counts of next-tick, microtask, macrotask, and poll events. |
CRUFT_EVENT_LOOP_COUNTERS_VERBOSE | The above, on every event rather than sampled. |
CRUFT_EVENT_LOOP_JOB_TRACE | A begin/end trace line per job, with kind and timing. |
CRUFT_PROFILE_EVENT_LOOP | Per-run timing profile of the loop; _JOBS and _VERBOSE variants add per-job and per-event detail. |
CRUFT_MICROTASK_BUDGET | Arms the microtask budget directly (the agent sets this for you). |
There are finer-grained timing counters over promise reactions, async-await resume, and job dispatch as well; each is a single CRUFT_* flag that only prints and never changes behavior.
Limitations
The loop is a stand-in for a libuv-style reactor in a few specific places:
- Idle wait is a poll-sleep, not an OS readiness wait. There is no unified
epoll/kqueuethe loop blocks on; readiness arrives on helper threads and is drained on a cadence bounded by the sleep constants (one millisecond for pending clients, up to one second when only timers are outstanding). Latency-sensitive workloads feel this floor. ref/unrefworks for timers, not for other handles. Anunref'd timer lets the process exit while it is still pending, matching Node. On sockets, servers, worker ports, stdin, and file watchers,ref/unrefare accepted but do nothing, so those handles keep the loop alive whether or not youunrefthem.setImmediatehas no dedicated phase. It is a zero-delay timer, so its ordering againstsetTimeout(…, 0)is registration order, not Node's immediates-phase guarantee.- Timer firing order is registration order, not soonest-deadline-first, among timers that come due in the same drain.
- A
queueMicrotaskcallback that throws is swallowed at the job boundary rather than surfaced as an uncaught error. - The blocking-await stand-in. Compiled async functions use real frame park-and-resume, but a small number of internal await paths (the low-level
__awaithelper and some async-generator delegate awaits) instead spin the loop synchronously until the promise settles, capped at 100,000 pumps. That path drains sibling microtasks and one macrotask mid-await rather than yielding cleanly, and it synthesizes a thrown error on a self-pending cycle instead of hanging. Ordinaryasync/awaitin your code does not take this path. beforeExitliveness is keyed on pending timers only, so it can fire earlier than Node when non-timer work (open sockets, in-flight I/O) is still outstanding.process.abort()is a no-op rather than aSIGABRTwith a core dump.