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:

next-tickprocess.nextTick callbacks. A Node-shaped lane, drained to empty ahead of every microtask.
microtasksPromise reactions, queueMicrotask, async/await continuations. The standard microtask lane.
macrotasksTimer callbacks, fired I/O completions, one per turn. The "tasks" lane.
poll_ioThe host seam. When all three queues are empty, the loop asks the host whether any OS I/O is ready.

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:

One turn
  1. Phase 1Drain the next-tick queue to empty, then run one microtask. Repeat until both next-tick and microtasks are empty.
  2. Phase 2Run exactly one macrotask, then start a new turn.
  3. Phase 3 · idleOnly when all three queues are empty, ask the host poll_io hook 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.nextTick and queueMicrotask land 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:

  • await always yields at least one microtask. Even await 5 or 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 own try stack is unwound to a live catch. A local try/catch around the await sees 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 AsyncLocalStorage frame that was active at the await, so a getStore() 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:

  • setImmediate is setTimeout(cb, 0). There is no separate "check" phase. A setImmediate and a setTimeout(…, 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/unref are 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:

SignalsOS signals with a JS listener (process.on('SIGINT', …)) — the most latency-sensitive, checked first.
NativeN-API async-work completions and threadsafe-function calls.
TimersDue setTimeout/setInterval/setImmediate callbacks.
HarvestsCompleted fetch/HTTP-client round-trips, child-process output, datagrams, IPC, HTTP/2, socket data.
ServersHTTP, TCP, and TLS server accept and dispatch.
StreamsChild-process liveness, worker channels, stdin.

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:

  1. 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; warn diagnoses and continues; none/silent stay quiet).
  2. beforeExit re-drains. The beforeExit event fires, and if a listener ran and scheduled more timer work, the loop runs again and beforeExit re-fires. This repeats up to a bounded number of rounds, so a listener that reschedules forever cannot wedge shutdown.
  3. exit fires once, synchronously, on the way out. A listener may still adjust process.exitCode, so the final code is re-read afterward.
  4. 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.

Op levelA per-run step budget counts every bytecode op; overrun is uncatchable. A wall-clock timeout is armed by a watchdog thread and observed at interpreter safepoints.
Job levelA microtask budget counts microtask dispatches. Set it with the agent's --max-microtasks; overrun aborts the offending job before it runs.
Loop levelA safety bound of ten million turns backstops a pathological enqueue storm, so the driver itself can never spin unbounded.

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:

VariableWhat it surfaces
CRUFT_EVENT_LOOP_COUNTERSPer-phase counts of next-tick, microtask, macrotask, and poll events.
CRUFT_EVENT_LOOP_COUNTERS_VERBOSEThe above, on every event rather than sampled.
CRUFT_EVENT_LOOP_JOB_TRACEA begin/end trace line per job, with kind and timing.
CRUFT_PROFILE_EVENT_LOOPPer-run timing profile of the loop; _JOBS and _VERBOSE variants add per-job and per-event detail.
CRUFT_MICROTASK_BUDGETArms 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/kqueue the 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/unref works for timers, not for other handles. An unref'd timer lets the process exit while it is still pending, matching Node. On sockets, servers, worker ports, stdin, and file watchers, ref/unref are accepted but do nothing, so those handles keep the loop alive whether or not you unref them.
  • setImmediate has no dedicated phase. It is a zero-delay timer, so its ordering against setTimeout(…, 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 queueMicrotask callback 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 __await helper 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. Ordinary async/await in your code does not take this path.
  • beforeExit liveness 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 a SIGABRT with a core dump.