When most developers think of JavaScript, the word "single-threaded" often comes to mind. But modern JS runtimes are far more sophisticated than the old "one thread, one call stack" stereotype. From the event loop and async/await to Web Workers, async iterators, and SharedArrayBuffers, today's JavaScript offers a rich (although muddled) concurrency landscape: one that spans browsers, Node.js / Bun / Deno, and edge environments.

Understanding how these layers of concurrency interact is essential for building responsive UIs, scalable backends, and reliable serverless functions. In this article, we'll break down the concurrency primitives and patterns available in modern JavaScript, show how they actually work, and show how to leverage them safely and effectively.

The Myth of Single-Threaded JavaScript

In a sense, JS is single-threaded: each execution context runs on a single call stack. An execution context is created for each script, function, or module, and it has its own stack where function calls are pushed and popped.

But this label can be misleading. Modern JavaScript runtimes support multiple forms of concurrency, enabling asynchronous and even parallel operations without blocking the main thread.

At the heart of JS concurrency is the event loop, which schedules and executes tasks cooperatively. Tasks are picked from the macrotask queue (timers, I/O callbacks, setTimeout) and microtask queue (promises, queueMicrotask) in a well-defined order. This mechanism allows JavaScript to perform asynchronous work while maintaining a single-threaded execution model.

Example:

console.log(1);
setTimeout(() => console.log(2));
Promise.resolve().then(() => console.log(3));
console.log(4);

// Output: 1, 4, 3, 2

In this snippet:

Key takeaway

JavaScript concurrency is cooperative, not parallel by default. Even though the runtime handles multiple pending operations, only one piece of JavaScript executes at a time in a given context.

The Event Loop as the Core Concurrency Engine

The event loop manages concurrency in JavaScript. Instead of thinking in terms of threads, it's easier to view it as a task scheduler that coordinates asynchronous operations while the main thread executes one piece of code at a time.

How the Event Loop Schedules Work

Runtime Nuances

Practical Takeaways

Beyond the Event Loop: Using Workers

While the event loop enables concurrency within a single thread, true parallelism in JavaScript requires workers. Workers run in separate execution contexts, allowing code to execute on multiple threads simultaneously without blocking the main thread.

Types of Workers

Communication

Workers do not share memory by default. They communicate through message passing using postMessage and events. For more advanced use cases, SharedArrayBuffer and Atomics allow shared memory, but careful synchronization is required.

Example: Using a Web Worker

// main.js
const worker = new Worker('worker.js');
worker.onmessage = (e) => console.log('Worker says:', e.data);
worker.postMessage('ping');

// worker.js
self.onmessage = (e) => {
  self.postMessage(e.data + ' pong');
};

This will log Worker says: ping pong.

Practical Implications

Key takeaway

Async Iterators: Structured Concurrency for Streams

While workers enable parallelism, not all concurrency requires multiple threads. Modern JavaScript provides async iterators as a way to handle ongoing asynchronous streams of data in a structured and predictable way. They let you consume asynchronous data incrementally, rather than waiting for the entire dataset or stream to be ready.

How Async Iterators Work

An async iterator implements the Symbol.asyncIterator method and exposes a next() method that returns a promise. You can use for await...of loops to consume values as they become available. This pattern is particularly useful for streaming APIs, event processing, or any scenario where data arrives over time.

Example:

async function* streamData() {
  for (let i = 0; i < 3; i++) {
    await new Promise(r => setTimeout(r, 100));
    yield i;
  }
}

(async () => {
  for await (const value of streamData()) {
    console.log(value);
  }
})();
// Output: 0, 1, 2

In this example, each value is produced asynchronously and consumed one by one without blocking the main thread.

Use Cases

Practical Implications

Key takeaway

Shared Memory and Atomics

For scenarios where multiple threads or workers need to coordinate and share data directly, JavaScript provides SharedArrayBuffer and Atomics. Unlike message-passing between workers, this allows threads to access the same memory space, enabling true shared-memory parallelism.

How Shared Memory Works

A SharedArrayBuffer is a special type of buffer that can be accessed by multiple workers simultaneously. To avoid race conditions, the Atomics API provides methods for atomic operations like read, write, add, and compare-and-swap, ensuring that operations on shared memory are executed safely.

Example:

// main.js
const sharedBuffer = new SharedArrayBuffer(4);
const counter = new Int32Array(sharedBuffer);

// Create two workers
const worker1 = new Worker('worker.js');
const worker2 = new Worker('worker.js');

// Send the shared buffer to both workers
worker1.postMessage(sharedBuffer);
worker2.postMessage(sharedBuffer);

worker1.onmessage = worker2.onmessage = () => {
  console.log('Final counter value:', Atomics.load(counter, 0));
};


// worker.js
self.onmessage = (e) => {
  const counter = new Int32Array(e.data);

  // Each worker increments the counter 1,000 times
  for (let i = 0; i < 1000; i++) {
    Atomics.add(counter, 0, 1);
  }

  self.postMessage('done');
};

// Output: Final counter value: 2000

Without Atomics, concurrent reads and writes could overwrite each other, causing inconsistent or unpredictable results. After both workers complete, the counter reliably shows 2000, demonstrating true concurrent updates.

Use Cases

Practical Implications

Key takeaway

Concurrency Across Runtimes

JavaScript's concurrency model behaves slightly differently depending on the runtime. Understanding these differences is crucial for writing efficient, non-blocking, and parallel code across browsers, server environments, and edge platforms.

Browsers

Node.js, Deno, Bun

Edge Environments

Practical Takeaways

Key takeaway

Structured Concurrency: The Missing Abstraction

Despite the rich set of concurrency primitives in JavaScript, managing multiple async tasks safely and predictably remains challenging. Developers often rely on patterns like Promise.all(), AbortController, or manual cleanup to coordinate related tasks, but these approaches can be error-prone and hard to reason about.

Structured concurrency is a concept being explored by TC39 via the JavaScript Concurrency Control Proposal, aiming to provide:

While still a proposal, structured concurrency represents a promising direction for making JavaScript concurrency safer and more manageable in the future.

Key takeaway

Determinism, Testing, and Debugging

Asynchronous and concurrent code introduces ordering and timing issues that can make bugs hard to reproduce. Even if your tasks aren't truly parallel, the cooperative nature of JavaScript concurrency means that the sequence in which async operations complete can affect program behavior.

Common Challenges

Tools and Patterns for Predictable Testing

Practical Implications

Key takeaway

The Future of JS Concurrency

JavaScript concurrency continues to evolve. New proposals, runtime improvements, and edge-focused patterns are shaping how developers will write async and parallel code in the coming years.

Practical Outlook

Closing Thought

Modern JavaScript concurrency is powerful but demands understanding. Between the event loop, workers, async iterators, shared memory, and emerging structured concurrency, developers have a rich toolkit, but must choose the right primitives for the right workload. The future promises safer, more ergonomic, and more predictable concurrency, making JavaScript a robust environment for both UI and backend parallelism.

Explore structured concurrency proposals, try out worker patterns in your projects, and experiment with async iterators to get hands-on experience with modern JavaScript concurrency.