Modern Concurrency Models
Language runtimes have converged on a few models. Each makes a different trade-off between shared state, scheduling, and failure handling.
The event loop
A single thread runs tasks; I/O is non-blocking and completions are queued.
call stack → microtasks (promises) → macrotasks (timers, I/O) → render (browser)
Rules:
- Never block the loop: CPU-heavy work goes to a worker/process.
- Microtasks (resolved promises,
queueMicrotask) run before the next macrotask; a microtask chain can starve timers and I/O. - One slow request blocks all requests on that loop.
setTimeout(() => console.log("macrotask"), 0);
Promise.resolve().then(() => console.log("microtask")); // runs first
The model is excellent for high-connection I/O servers (Node, Deno, browsers) and terrible for CPU-bound work unless you offload it.
Async/await, done properly
async functions are syntactic sugar over promises. Correctness issues are usually about concurrency control, not syntax.
Sequential vs parallel
// Sequential: total = sum of latencies
const a = await fetchA();
const b = await fetchB();
// Parallel: total ≈ max(latency)
const [a, b] = await Promise.all([fetchA(), fetchB()]);
Bounded parallelism
Promise.all over 10,000 items opens 10,000 operations. Use a pool:
async function mapLimit<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let next = 0;
async function worker() {
while (true) {
const index = next++;
if (index >= items.length) return;
results[index] = await fn(items[index]);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return results;
}
Error handling
Promise.allrejects on the first error; the others keep running (no implicit cancellation).Promise.allSettledreturns all outcomes — use it when partial success is meaningful.- Always attach a handler; unhandled rejections can crash Node.
Cancellation
Pass an AbortSignal through every layer:
async function withTimeout<T>(fn: (signal: AbortSignal) => Promise<T>, ms: number) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms);
try {
return await fn(controller.signal);
} finally {
clearTimeout(timer);
}
}
Without cancellation, a timed-out request keeps consuming resources.
CSP and channels (Go)
"Don't communicate by sharing memory; share memory by communicating."
jobs := make(chan Job, 100)
results := make(chan Result, 100)
for i := 0; i < 8; i++ {
go func() {
for job := range jobs {
results <- process(job)
}
}()
}
Strengths:
- ownership moves through channels;
selecthandles multiple channels/timeouts;- pipelines and worker pools are natural.
Pitfalls:
- goroutine leaks (blocked forever on send/receive);
- closing a channel twice, or sending on a closed channel;
- unbounded channels hiding backpressure;
- deadlock when producers/consumers wait on each other.
select {
case results <- r:
case <-ctx.Done():
return
}
Always give blocking operations a cancellation path.
Actors
An actor has private state and a mailbox; it processes one message at a time and can create other actors.
actor Counter {
var value = 0
on Increment(n) { value += n; sender ! value }
}
Strengths:
- no shared mutable state by construction;
- per-actor ordering;
- supervision trees isolate failures (Erlang/Elixir, Akka).
Pitfalls:
- mailbox overload (unbounded queue = OOM);
- distributed actors add network partitions and message loss;
- request/response patterns can still deadlock (two actors awaiting each other);
- debugging async message flows is harder than stacks.
Structured concurrency
The idea: concurrent tasks are scoped, like blocks. No task outlives its parent; cancellation and errors propagate.
try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask { try await fetchUsers() }
group.addTask { try await fetchOrders() }
var results: [Data] = []
for try await result in group { results.append(result) }
return results
}
coroutineScope {
val users = async { fetchUsers() }
val orders = async { fetchOrders() }
users.await() + orders.await()
}
Benefits:
- no orphan tasks/leaks;
- failure cancels siblings;
- timeouts apply to the whole scope.
Java's StructuredTaskScope and Kotlin/Swift runtimes make this the modern default.
Work stealing and virtual threads
- Work stealing (Go, Java ForkJoin, Tokio): idle workers steal tasks from busy queues, improving utilization.
- Virtual threads (Java 21+): many cheap threads multiplexed on carriers; blocking code scales without callback hell. Watch for pinning (blocking in
synchronized, native calls) and unbounded thread creation.
These reduce the cost of concurrency, not the need to bound it.
Choosing
| Need | Model |
|---|---|
| Many I/O connections, simple code | event loop / async-await |
| CPU-bound parallel work | worker threads/processes, work stealing |
| Pipeline with backpressure | channels/CSP, queues |
| Stateful services, fault isolation | actors + supervision |
| Scoped task trees, cancellation | structured concurrency |
| Existing blocking code at scale | virtual threads |
Failure modes across models
| Model | Typical failure |
|---|---|
| Event loop | blocked loop, unhandled rejection, unbounded microtasks |
| Promises | missing await, unbounded Promise.all, no cancellation |
| CSP | goroutine leak, closed-channel panic, blocked send |
| Actors | mailbox overflow, deadlock on request/response, poison message |
| Structured | cancellation swallowed, scope too wide |
| Virtual threads | pinning, thread-per-task without limits |
Checklist
- The model matches the workload (I/O vs CPU vs stateful).
- Concurrency is bounded everywhere.
- Every long-running task has cancellation.
- Errors propagate to an owner that can act.
- Queues have limits and backpressure.
- Shutdown cancels/awaits tasks and releases resources.
- The team can debug the model (traces, logs, task ids).
- No task outlives the request/scope that created it.
"The best concurrency model is the one whose failure modes your team can recognise at 3 a.m."