Async Programming Promises Async Await Timers and Cancellation

Reading time
8 min read
Word count
1471 words
Diagram count
0 diagrams

Source: Victor Bona's Obsidian Compendium snapshot, Knowledge base/nodejs-v8-runtime-engineering/08 Async Programming Promises Async Await Timers and Cancellation.md.

Purpose: Build a production mental model for Node.js async control flow: promises, async functions, event loop turns, timers, cancellation, and the failure modes that appear when these pieces are composed under load.

Async Programming, Promises, Async Await, Timers, and Cancellation

Parent map: Node.js V8 Runtime Engineering

Related notes:

Operating model

Node.js async programming is not "parallel JavaScript." It is a coordination model over:

LayerWhat it ownsCommon APIsProduction failure shape
V8 microtasksPromise reactions and queueMicrotask() callbacksawait, .then(), .catch(), .finally()Starves timers and I/O if recursively filled
Node next tick queueNode-specific callbacks that run before promise microtasks in many boundariesprocess.nextTick()Can starve the event loop harder than promises
libuv event loopTimers, poll, check, close callbacks, async I/O readinesssetTimeout, setImmediate, sockets, fs callbacksLatency spikes when callbacks do CPU work
libuv threadpoolOffloaded blocking-ish native operationsfs, selected crypto, zlib, DNS getaddrinfoPool saturation makes unrelated operations slow
OS and kernelReadiness, signals, process scheduling, filesystem behaviorepoll, kqueue, IOCP, POSIX signalsPlatform-specific edge cases

The main JavaScript thread advances by running one callback, draining relevant microtasks, then returning to the event loop. Any callback that does CPU work, synchronous I/O, JSON parsing of huge payloads, or recursive microtask scheduling prevents other callbacks from running.

Promise fundamentals

A promise is a state machine for one eventual result. It is not a task scheduler by itself. The underlying operation starts because some API started it, not because a promise exists.

StateMeaningWhat handlers see
PendingNo result yetNothing runs yet
FulfilledCompleted with a valuethen and await resume with value
RejectedCompleted with a reasoncatch or try/catch receives reason
SettledFulfilled or rejectedfinally runs either way

Field rules:

  • Every promise chain must have an ownership boundary for errors: await inside try/catch, return the promise to a caller that awaits it, or deliberately attach .catch().
  • A floating promise is a background task. Treat it like a spawned process: track it, cancel it, log failures, and define shutdown behavior.
  • Promise.all() is fail-fast for the aggregate result, but it does not cancel sibling work. Use AbortSignal if sibling work must stop.
  • Promise.allSettled() is for batch accounting. It is not a substitute for deciding which failures are recoverable.
  • Promise.race() does not cancel losers. It only picks the first settled result.
  • Promise.any() ignores rejections until all inputs reject, then throws AggregateError.

Async and await

async functions always return promises. await unwraps a promise-like value and yields back to the host so other microtasks and event loop work can continue later.

async function loadUserProfile(userId, { signal }) {
  const user = await fetchUser(userId, { signal });
  const settings = await fetchSettings(user.tenantId, { signal });
  return { user, settings };
}

Sequential awaits are correct when step 2 depends on step 1. They are accidental latency when the work is independent.

async function slowIndependentWork(id) {
  const profile = await fetchProfile(id);
  const permissions = await fetchPermissions(id);
  return { profile, permissions };
}

async function fasterIndependentWork(id) {
  const [profile, permissions] = await Promise.all([
    fetchProfile(id),
    fetchPermissions(id),
  ]);
  return { profile, permissions };
}

Production guidance:

  • Put independent I/O in bounded parallel groups, not unbounded map(async ...).
  • Keep CPU work out of callbacks and promise reactions. Move heavy computation to 10 Filesystem Processes Signals Workers Cluster and Child Processes when it cannot be chunked.
  • Preserve causality in errors by wrapping with cause.
  • Do not mix callback and promise completion for the same operation unless the API explicitly supports it.
  • Use finally for cleanup that must run after success or failure, but do not hide the original failure by throwing a cleanup error without care.

Microtasks, next ticks, and starvation

Promise reactions are microtasks. queueMicrotask() also schedules microtasks. Node's process.nextTick() is even more aggressive: it is intended for small compatibility callbacks, not general scheduling.

function badForever() {
  Promise.resolve().then(badForever);
}

function alsoBadForever() {
  process.nextTick(alsoBadForever);
}

These loops can prevent timers and I/O from progressing. If you need to yield to the event loop, prefer setImmediate() or timers/promises.scheduler.yield() where available.

Timer APIs

Node timers are global, but node:timers and node:timers/promises make ownership explicit.

APIPhase or behaviorBest use
setTimeout(fn, ms)Runs after at least ms, subject to event loop loadDelays, deadlines, retries
setInterval(fn, ms)Repeats after delay windowsSimple periodic jobs with overlap protection
setImmediate(fn)Runs after I/O callbacks in the check phaseYielding after current poll work
queueMicrotask(fn)Runs before returning to event loopTiny follow-up work after current stack
timers/promises.setTimeout(ms, value, options)Promise delay with optional signalAwaitable sleeps and deadlines
timers/promises.scheduler.yield()Awaitable cooperative yieldLet I/O and timers breathe in long async loops

Timer precision is not a real-time guarantee. A 50 ms timeout runs no earlier than roughly 50 ms, but it can run much later if the event loop is busy.

import { setTimeout as sleep } from "node:timers/promises";

async function retryWithBackoff(operation, { signal, attempts = 5 }) {
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    try {
      return await operation({ signal });
    } catch (error) {
      if (signal.aborted || attempt === attempts) throw error;
      const delay = Math.min(1000, 50 * 2 ** (attempt - 1));
      await sleep(delay, undefined, { signal });
    }
  }
}

Ref and unref

By default, active Timeout and Immediate objects keep the process alive. unref() says "do not keep the event loop alive just for this handle."

const timeout = setTimeout(() => {
  console.error("cleanup did not finish before deadline");
}, 30_000);

timeout.unref();

Use unref() for watchdogs, telemetry flush deadlines, cache refreshes, and idle cleanup. Do not use it for work that must complete before exit.

setInterval without overlap

Intervals do not understand async callback completion. If the callback takes longer than the interval, calls can overlap.

let running = false;

const timer = setInterval(async () => {
  if (running) return;
  running = true;
  try {
    await refreshCache();
  } finally {
    running = false;
  }
}, 10_000);

timer.unref();

For critical jobs, prefer an explicit loop:

import { setTimeout as sleep } from "node:timers/promises";

async function runPeriodicJob({ signal }) {
  while (!signal.aborted) {
    const started = Date.now();
    await refreshCache({ signal });
    const elapsed = Date.now() - started;
    await sleep(Math.max(0, 10_000 - elapsed), undefined, { signal });
  }
}

Cancellation model

AbortController and AbortSignal are the standard cancellation vocabulary across modern Node APIs. Cancellation is cooperative: calling abort() asks participating APIs to stop. It does not magically kill arbitrary JavaScript work already running on the main thread.

PrimitiveRoleNotes
new AbortController()Owns the cancellation decisionPass only signal to callees
controller.abort(reason)Marks signal as aborted and notifies listenersPrefer meaningful reasons
AbortSignal.timeout(ms)Creates a signal that aborts after a delayGood for local deadlines
AbortSignal.any(signals)Creates a signal that aborts when any input abortsGood for combining caller cancellation and deadline
signal.throwIfAborted()Fast fail before doing workUse at operation boundaries
async function withTimeout(promiseFactory, ms, parentSignal) {
  const timeoutSignal = AbortSignal.timeout(ms);
  const signal = parentSignal
    ? AbortSignal.any([parentSignal, timeoutSignal])
    : timeoutSignal;

  signal.throwIfAborted();
  return promiseFactory({ signal });
}

Cancellation contract for your own functions:

  • Accept an options object with { signal }.
  • Call signal.throwIfAborted() before starting expensive work.
  • Pass signal to child APIs that support it.
  • For custom async loops, check signal.aborted between awaits and chunks.
  • Remove abort listeners or use { once: true } to avoid listener leaks.
  • Reject with an abort-shaped error rather than pretending the operation succeeded.

Deadlines and budgets

Timeouts should be part of a propagated budget, not random constants at every layer.

async function handleRequest(req) {
  const requestDeadline = AbortSignal.timeout(2_000);
  const user = await readUser(req.params.id, { signal: requestDeadline });
  const result = await calculateResponse(user, { signal: requestDeadline });
  return result;
}

Better systems pass a single request signal through database calls, HTTP calls, stream pipelines, and child processes. See 09 Streams Buffers Backpressure and Binary Data for stream pipeline cancellation and 10 Filesystem Processes Signals Workers Cluster and Child Processes for child process cancellation.

Bounded concurrency

Unbounded concurrency turns one request into a denial of service against the same process.

async function mapLimit(items, limit, worker) {
  const results = new Array(items.length);
  let next = 0;

  async function run() {
    while (next < items.length) {
      const index = next;
      next += 1;
      results[index] = await worker(items[index], index);
    }
  }

  await Promise.all(
    Array.from({ length: Math.min(limit, items.length) }, run),
  );

  return results;
}

Choose limits from downstream capacity, not CPU count alone:

Work typeLimit signal
Database queriesConnection pool size and query cost
HTTP API callsRate limits, remote latency, retry budget
Filesystem readslibuv threadpool size and disk behavior
CPU transformsWorker count and memory pressure
Stream fanoutBackpressure and writable high water marks

Error handling

Use structured error boundaries. Do not rely on process-wide handlers for normal control flow.

async function main() {
  const controller = new AbortController();
  installSignalHandlers(controller);

  await startServer({ signal: controller.signal });
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

unhandledRejection and uncaughtException are last-resort observability hooks. They are not recovery mechanisms for corrupted application state.

Common footguns

FootgunSymptomSafer pattern
array.forEach(async item =&gt; ...)Caller does not waitUse for...of, Promise.all, or bounded concurrency
Missing .catch() on background promiseDelayed unhandled rejectionTrack task and attach error handler
Promise.race([op, timeout]) without cancellationTimed-out work keeps runningRace plus AbortController
Long then chain with CPU workEvent loop lagWorker thread or chunked yielding
Recursive process.nextTick()Process appears hungUse setImmediate for yielding
setInterval(async () =&gt; ...)Overlapping jobsGuard, queue, or explicit loop
Catching and returning defaults everywhereSilent data corruptionClassify errors and preserve causes
New timeout at every layerRequests exceed real SLO anywayPropagate one deadline signal

Troubleshooting async incidents

SymptomFirst checksLikely cause
Timers firing lateEvent loop delay, CPU profile, sync APIsCPU-bound callback or synchronous I/O
Process will not exitActive handles, timers, servers, socketsRef'ed timer or open resource
Work continues after client disconnectSignal propagation, stream pipeline, child process optionsMissing cancellation
Memory grows during batchIn-flight promise count, retained closuresUnbounded concurrency
Intermittent AbortErrorDeadline budget, parent signal, retry logicCorrect cancellation or too-short timeout
High p99 latency under fs loadThreadpool saturation, disk metricsToo many fs operations or crypto jobs

Production checklist

  • Every request gets a deadline signal.
  • Every externally visible async operation accepts { signal }.
  • Background tasks are named, tracked, cancellable, and observed.
  • Parallelism is bounded at service edges.
  • Timers that should not keep the process alive call unref().
  • Timer callbacks are idempotent and overlap-safe.
  • Promise.all() groups are paired with cancellation when partial failure should stop siblings.
  • Process-level rejection handlers log and crash or drain intentionally.
  • Event loop delay is measured in production.
  • Heavy CPU work is moved to worker threads or child processes described in 10 Filesystem Processes Signals Workers Cluster and Child Processes.

Official docs checked