Link copied!
Understanding JavaScript's Concurrency Model: How the Event Loop and Job Queues Work Technical Log

TechiesAIE Journal

Understanding JavaScript's Concurrency Model: How the Event Loop and Job Queues Work

TechiesAIE
TechiesAIE
Lead Developer · TechiesAIE
4 min read 802 words

Based on the sources linked below.

Cover image: Martin Vorel · CC BY-SA 4.0 · License · Image source

JavaScript operates on a single-threaded, non-blocking execution model, which allows it to handle asynchronous operations efficiently without freezing the main thread. This behavior is managed by a core mechanism known as the event loop, which processes jobs from various queues in a specific order.

The JavaScript Agent and its Components

In the JavaScript specification, an "agent" represents an autonomous executor of JavaScript code, analogous to a thread. Each agent maintains three key facilities for code execution: a heap for objects, a queue for jobs (often called the event loop), and a stack for execution contexts. These distinct data structures enable the runtime environment to manage memory, asynchronous tasks, and synchronous code execution.

The heap is a region of memory populated as objects are created. The stack, or call stack, is a last-in-first-out (LIFO) structure that tracks execution contexts, such as function calls, managing variable environments and return points. The queue, known as the event loop, is a first-in-first-out (FIFO) mechanism for asynchronous programming, ensuring that callbacks for completed asynchronous actions are processed in order.

For web environments, agents can be associated with similar-origin windows, dedicated workers, shared workers, service workers, or worklets. In Node.js, a similar concept is available through worker threads.

Synchronous Execution: The Call Stack

When synchronous JavaScript code runs, it creates an initial execution context (stack frame) for the job. As functions are called, new execution contexts are pushed onto the stack, tracking local variables, parameters, and the code's evaluation state. When a function returns, its context is popped off the stack. This process continues until the main job's stack frame is popped, signifying completion.

For example, if `bar(7)` calls `foo(x * y)`, contexts for `bar` and `foo` are pushed onto the stack sequentially. `foo` completes first, its context is popped, then `bar` completes and its context is popped. This LIFO behavior is standard for synchronous function execution.

Asynchronous Execution: The Event Loop and Job Queue

JavaScript's single-threaded nature means only one statement can be processed at a time. To prevent blocking during asynchronous operations like I/O or timers, these actions are offloaded to the host environment (e.g., browser or Node.js). Once an asynchronous action completes, its associated callback—which defines a "job"—is placed into a job queue.

The event loop continuously pulls jobs from this queue and executes them when the call stack is empty. Each job runs to completion, meaning it cannot be preempted by other code. This ensures predictable execution order for callbacks, even when they appear to create race conditions, as demonstrated with `Promise.resolve()` callbacks.

Node.js Event Loop Phases

The Node.js event loop organizes jobs into distinct phases, each with its own FIFO queue of callbacks. These phases include: timers, pending callbacks, poll, check, and close callbacks. The event loop moves sequentially through these phases, executing callbacks until a queue is exhausted or a system-dependent limit is reached.

The `timers` phase executes callbacks scheduled by `setTimeout()` and `setInterval()`. The `poll` phase retrieves new I/O events and executes related callbacks. If the `poll` queue is empty and `setImmediate()` scripts are scheduled, the event loop moves to the `check` phase to execute them. Otherwise, it waits for I/O events. The `check` phase is specifically for `setImmediate()` callbacks, designed to run immediately after the `poll` phase completes.

process.nextTick() vs. setImmediate() vs. setTimeout()

While `setTimeout(callback, 0)` and `setImmediate()` appear similar, their execution order depends on the context. If called from the main module, their order is non-deterministic. However, within an I/O cycle (e.g., `fs.readFile()` callback), `setImmediate()` callbacks are always executed before `setTimeout()` callbacks.

`process.nextTick()` is not technically part of the event loop phases. Instead, its callbacks are processed immediately after the current operation completes, regardless of the event loop's current phase, and before the event loop continues to the next phase. This allows developers to guarantee that a callback runs after the current JavaScript execution stack unwinds but before any I/O or timer callbacks are processed. This can be useful for error handling, resource cleanup, or ensuring event handlers are set before events are emitted, but recursive `process.nextTick()` calls can starve the I/O loop.

Practical Implications and Limitations

The "run-to-completion" nature of jobs means that if a single job takes too long, the application becomes unresponsive, as user interactions cannot be processed until that job finishes. Good practice involves keeping job processing short and splitting long tasks into multiple smaller jobs to maintain responsiveness. The guarantee that JavaScript execution is "never blocking" relies on platform APIs being inherently asynchronous, though some legacy synchronous APIs exist (e.g., `alert()`) that should be avoided.

Understanding this model is crucial for writing efficient and responsive JavaScript applications, especially when dealing with complex asynchronous flows and managing concurrency in both browser and Node.js environments.

Sources