JavaScript concurrency: the event loop, races, and backpressure
Understand async execution, avoid stale responses, and limit parallel work without blocking the interface.
JavaScript can coordinate many network requests while a page remains interactive, yet a single expensive calculation can freeze the same interface. Understanding that difference helps explain why adding async sometimes improves responsiveness and sometimes changes nothing.
This article focuses on browser JavaScript. Node.js has related concepts but different host scheduling details, so browser timer examples should not be treated as a complete description of Node’s event loop.
Follow one execution turn
Ordinary JavaScript on a single agent runs a job to completion before another queued job executes. The browser coordinates events and asynchronous operations around that execution. Promise continuations use microtasks, which are processed after the current execution finishes before a later task is selected in this simple example.
console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve().then(() => console.log('C'));
console.log('D');
// A, D, C, BThe zero-delay timer does not interrupt the current stack. It schedules later work, and its requested delay is not an exact execution time. The MDN execution model guide describes jobs, stacks, and run-to-completion behavior.
Async does not move computation to another thread
An async function runs synchronously until it reaches a suspension point. A long loop before that point still occupies the current thread. Wrapping a CPU-heavy transformation in Promise.resolve() does not make the transformation parallel.
If two independent requests each take around 200 milliseconds, starting them together can reduce waiting compared with starting the second after the first completes. That does not imply a twofold speedup for two CPU-heavy calculations on the same JavaScript thread. Concurrency means work can be in progress over overlapping periods; parallelism means execution occurs at the same time on separate resources.
For substantial browser computation, a Web Worker can move work off the main thread. Workers communicate through messages and do not directly manipulate the page’s DOM. Account for startup and data-transfer costs before moving tiny calculations. See MDN’s worker guide.
A single thread can still have logical races
Imagine a user types “react” and then “react server.” The first request starts earlier but finishes later. If every completion replaces the displayed result, the interface ends up showing stale results for the shorter query.
Cancellation reduces wasted work, while a request sequence protects the display from stale completions:
let latest = 0;
let active: AbortController | undefined;
async function search(query: string) {
const request = ++latest;
active?.abort();
const controller = new AbortController();
active = controller;
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error('Search failed');
const results = await response.json();
if (request === latest) renderResults(results);
} catch (error) {
if (request === latest && !controller.signal.aborted) {
renderError(error);
}
}
}renderResults and renderError are application-specific placeholders. In a component, keep this coordination scoped to that component instance and clean up on unmount. Module-wide state would make separate search widgets interfere with one another.
Aborting the browser request does not prove that the server stopped processing it. For operations with side effects, use a server-side protocol that handles duplicates and uncertain outcomes. Client cancellation is primarily control over the client’s interest in the response.
Bound concurrency before it becomes overload
Promise.all(items.map(upload)) can launch an operation for every item immediately. With a large list, that can exhaust connections, memory, or downstream quotas. A small worker pool limits active operations:
async function mapLimit<T, R>(
items: T[], limit: number, work: (item: T) => Promise<R>
): Promise<R[]> {
if (!Number.isInteger(limit) || limit < 1) {
throw new Error('limit must be a positive integer');
}
const results = new Array<R>(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const index = next++;
results[index] = await work(items[index]);
}
}
await Promise.all(
Array.from({ length: Math.min(limit, items.length) }, worker)
);
return results;
}Each worker claims an index before awaiting, so this single-agent example does not assign the same index twice. Results retain input order, regardless of completion order. This implementation rejects on a worker failure but does not cancel other workers; it is suitable only when that behavior is acceptable. Production requirements may call for shared cancellation, collected per-item outcomes, or stopping new scheduling after failure.
A concurrency limit is not a requests-per-second limit. Fast tasks can still produce a high request rate. If a provider imposes a rate quota, add a time-aware limiter and account for retries within the same budget.
Backpressure is a capacity decision
A worker pool bounds active work but this example still stores the whole input and result arrays. An unbounded stream needs more: a bounded queue, a producer that waits when capacity is exhausted, or an explicit rejection policy. Otherwise a slow downstream service simply moves the overload into memory.
Measure queue wait time separately from processing time. A task that takes 100 milliseconds to execute but waits twenty seconds is still a slow user experience. Set deadlines that include waiting, and decide what happens when work becomes stale.
Practice by forcing the awkward timing
Use a mock server that deliberately returns earlier searches later. Make one worker fail while others are active. Test a large input and observe the number of in-flight operations. These experiments expose coordination bugs more reliably than repeatedly testing the fastest happy path.
Responsive software comes from controlling both execution and demand: keep expensive work away from critical interaction, prevent stale results from winning, and admit only as much work as the system can handle.