JavaScript Async Patterns and Event Loop¶
Comprehensive reference for JavaScript asynchronous programming - from callbacks through async/await to streams and signals. Covers V8 event loop internals, Node.js-specific APIs, and modern reactive patterns.
Key Facts¶
- JavaScript async has a contract hierarchy: callbacks -> promises -> async/await -> events -> streams -> signals -> locks -> iterators
- The event loop drains the microtask queue (Promise
.then,queueMicrotask) between every phase process.nextTickruns before any other microtask, before the next event loop phaseawaitsuspends function execution and yields to event loop - it does NOT block the thread- Unhandled rejections crash Node.js 15+ by default
- libuv default thread pool size = 4 threads (
UV_THREADPOOL_SIZEenv var to change)
Patterns¶
Event Loop Phases¶
The event loop executes phases sequentially per tick:
- timers -
setTimeoutandsetIntervalcallbacks - pending callbacks - deferred I/O callbacks from previous iteration
- idle/prepare - internal use
- poll - retrieve new I/O events; blocks here if no timers pending
- check -
setImmediatecallbacks - close callbacks - socket close events
Between each phase: microtask queue is fully drained.
Reactor pattern: libuv notifies event loop when I/O is ready (non-blocking). Proactor pattern: completion-based - operation completes before notification.
Callback Contract¶
// Callback-last, error-first convention
function doAsync(arg, callback) {
callback(null, result); // success
callback(new Error('msg')); // failure
}
Key rule: a callback must be called either synchronously or asynchronously, never both (Zalgo problem). Use process.nextTick or queueMicrotask to force async.
Promise API¶
Promise states: pending -> fulfilled | rejected (immutable once settled).
Promise.all(iterable) // rejects on first rejection
Promise.allSettled(iterable) // always resolves with {status, value|reason} array
Promise.race(iterable) // settles with first settlement (any direction)
Promise.any(iterable) // resolves with first fulfillment; AggregateError if all reject
Promise.withResolvers() // returns {promise, resolve, reject} (newer API)
Promise.try(fn) // wraps sync-or-async, catches sync throws too
Thenable contract: any object with .then(onFulfilled, onRejected). await works on any thenable, not just native Promises.
Node.js Streams¶
Four stream types: - Readable - source of data (read(), on('data'), for await...of) - Writable - destination (write(), end()) - Duplex - both readable and writable (TCP socket) - Transform - Duplex that transforms data (gzip, cipher)
Backpressure: when consumer is slower than producer, write() returns false, emit drain when ready. Automatic with pipe().
Web Streams API (browser + Node 18+): ReadableStream, WritableStream, TransformStream. Works natively in fetch Response.body.
Generators and Async Iterators¶
function* generator() {
yield 1;
yield 2;
return 3; // done:true
}
// Async generator for paginated APIs, event streams
async function* asyncGen() {
yield await fetch('/api');
}
for await (const item of asyncGen()) { ... }
for...of uses iterator protocol. Spread [...iter], destructuring, and Array.from() all consume iterables.
Signals (Reactive State)¶
Modern reactive primitive (Angular Signals, Solid.js, Preact Signals, TC39 proposal):
signal(initialValue)- reactive state containercomputed(fn)- derived value, lazily recomputed when dependencies changeeffect(fn)- side effect that re-runs when dependencies change
Signals track dependencies automatically at access time. Unlike RxJS - synchronous propagation, no subscription management.
AbortController Cancellation¶
const controller = new AbortController();
const { signal } = controller;
fetch(url, { signal })
.catch(err => {
if (err.name === 'AbortError') { /* cancelled */ }
});
setTimeout(() => controller.abort(), 5000);
Pass signal through async chains. Check signal.aborted or signal.throwIfAborted() in custom async code.
AsyncLocalStorage¶
Thread-local-storage equivalent for async code:
const als = new AsyncLocalStorage();
als.run({ userId: 123 }, async () => {
await someAsyncOp();
als.getStore(); // { userId: 123 } - available in callbacks too
});
Use for request context propagation (tracing, auth, logging) without parameter threading.
Gotchas¶
- A callback must never be called both sync and async (Zalgo problem) - always pick one
process.nextTickcan starve the event loop if called recursively- libuv thread pool (default 4) is shared across file I/O, DNS, and crypto - can become a bottleneck
Error.causeoption (new Error('context', { cause: originalError })) is the modern way to wrap errors- Web Locks API (
navigator.locks.request(name, fn)) works in browser + Node 18+ for named exclusive/shared locks - Legacy patterns to avoid:
function*/yieldfor async, Deferred, Async.js, RxJS for simple cases
See Also¶
- javascript concurrency primitives - Semaphore, Mutex, AsyncQueue, Worker Threads
- go concurrency - comparison: goroutines vs JS event loop model