Asynchrony §
Asynchrony handles many I/O operations on a single thread without blocking. It is relevant when a server must handle thousands of concurrent connections, or when a UI must remain responsive while waiting for network responses.
Asynchrony is a prerequisite for distributed systems: non-blocking I/O allows a single process to maintain thousands of network connections, handle timeouts and retries without stalling, and implement circuit breakers. Without it, every connection that waits for a remote node would pin an OS thread, making horizontal scaling impossible at the single-node level.
Authoritative sources
Event loop §
A single thread multiplexes many I/O operations. It registers callbacks for events (socket readable, timer expired) and dispatches them in order. The kernel’s epoll (Linux), kqueue (BSD/macOS), or IOCP (Windows) notifies the loop when a file descriptor is ready, avoiding busy-waiting.
Used in Node.js, Nginx (one event loop per worker process), Redis, and the browser’s main thread.
Details
The event loop is a concurrency pattern, not a parallelism pattern: it achieves high throughput on a single core by never blocking. The key insight is that I/O is the bottleneck, not computation: a server spends most of its time waiting for network data, not processing it.
The loop phases (Node.js):
- Timers: execute
setTimeout/setIntervalcallbacks. - Pending callbacks: execute system-level callbacks (e.g., TCP errors).
- Idle, prepare: internal use.
- Poll: retrieve new I/O events; execute I/O callbacks.
- Check: execute
setImmediatecallbacks. - Close callbacks: execute close handlers (e.g.,
socket.on('close')).
A blocking operation (synchronous file I/O, CPU-intensive computation) stalls the entire loop. This is why Node.js uses worker threads for CPU-bound work, and why Parallelism (thread pools) complements asynchrony: the event loop handles I/O, and worker threads handle computation.
The event loop is the scheduler (runtime mechanism); async/await is the programming model (developer-facing syntax) that lets the developer express continuations without manual callback chaining.
async/await §
async/await is syntactic sugar for continuations. await expr suspends the current coroutine and resumes it when expr completes. The event loop drives the coroutines.
Details
async/await desugars into a state machine: each await is a suspension point. The compiler generates an enum (state machine) whose variants represent the states between suspension points. Resuming is a function call into the state machine. This is why async functions are cheap to create (no OS thread) but cannot be cancelled mid-instruction.
In Go, goroutines are the runtime’s coroutines; see Message Passing for the channel model and Parallelism for the M:N scheduler.
In Rust, async fn generates a Future (an enum-based state machine implementing the Future trait). The executor (tokio, or smol as a lightweight alternative) polls the future on the event loop.
Limitations and pitfalls §
Asynchrony trades thread isolation for a single-threaded execution model, introducing several practical difficulties.
Details
Callback hell (pre-async/await): chaining multiple asynchronous operations produces deeply nested callbacks that are hard to read and maintain.
// Pre-async/await: three sequential I/O operations
fs.readFile('a.txt', (err, a) => {
fs.readFile('b.txt', (err, b) => {
fs.writeFile('out.txt', a + b, (err) => {
// done
});
});
});
Error-handling complexity: in early JavaScript, try/catch cannot cross an await boundary inside a callback. Each callback must handle its own error, and a missed err check silently swallows the failure. async/await restores structured error handling, but only when the entire call chain is async.
Fragmented stack traces: because a coroutine is suspended and resumed across multiple event-loop iterations, the call stack at the point of an error does not include the frames from before the suspension. Debugging requires async-aware tooling (e.g., V8’s --async-stack-traces).
Head-of-line blocking: if one I/O operation is slow (or a callback performs a long synchronous computation), the entire loop is stalled and every other pending connection waits. A single fs.readFileSync on a slow disk can freeze 10,000 connections. Mitigations: offload to worker threads, enforce timeouts, and use circuit breakers (see Fault Tolerance).
Example problem §
Problem: A Node.js server receives 10,000 concurrent HTTP requests, each requiring a 50 ms database query. Compare the thread-per-request model (one OS thread per connection) with the event-loop model (one thread, non-blocking I/O). What are the memory and throughput implications?
Solution
Thread-per-request:
- 10,000 threads × 8 MB stack (typical) = 80 GB of stack memory.
- Context switches: 10,000 threads on 8 cores → heavy scheduler overhead.
- Throughput limited by thread creation/switching cost.
Event loop:
- 1 thread, 10,000 file descriptors in
epoll. - Memory: 10,000 × (socket buffer + small state) ≈ a few hundred MB.
- Throughput: limited by I/O bandwidth, not by thread switching.
- The 50 ms DB query is non-blocking: the loop registers a callback and continues serving other requests.
| Thread-per-request | Event loop | |
|---|---|---|
| Memory | ~80 GB (stacks) | ~200 MB (fd state + buffers) |
| Context switches | ~10,000 per scheduling round | 0 (single thread) |
| Throughput ceiling | Thread creation / switch cost | I/O bandwidth |
| Failure mode | Thread exhaustion → OOM | One blocking call stalls all connections |
The event loop wins by orders of magnitude in memory and context-switch cost. The trade-off: a single blocking call (e.g., synchronous file I/O) stalls all 10,000 connections.