TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

JavaScript Event Loop Explained: Handling Thousands of Connections

Practical insights, clear explanations, and useful ideas for understanding the web.

October 25, 20269 min read5 topics
JavaScript Event Loop Explained: Handling Thousands of Connections
Explore the details
#JavaScript#Event Loop#Asynchronous Programming#Node.js#Web Workers
October 25, 20269 min read

JavaScript is often described as single-threaded, yet JavaScript servers can keep thousands of connections open at once. That sounds contradictory until you distinguish running JavaScript from waiting for network or file operations. The event loop lets a process make progress on other work while an operation is pending, rather than blocking the JavaScript thread until it finishes.

This guide explains the call stack, tasks, microtasks, promises, and non-blocking I/O, then compares browser JavaScript, Node.js, and worker threads. The key caveat: handling many connections efficiently does not mean JavaScript can run unlimited CPU-heavy work at the same time.

Key takeaways

  • A JavaScript execution agent runs one piece of JavaScript at a time on its call stack.
  • The event loop schedules more work when the current stack is clear; it does not make synchronous JavaScript parallel.
  • Promises represent eventual results. Their callbacks run as microtasks, but the operation producing a result may or may not involve other threads.
  • Non-blocking I/O allows a program to wait for network activity without keeping the JavaScript call stack occupied.
  • Browser workers and Node.js worker threads can run JavaScript on separate threads. They are different from the ordinary event loop and require communication between workers.

What “single-threaded” means

JavaScript code runs within an execution context, often called an agent. An agent has its own call stack and processes one JavaScript job at a time. If a function is running synchronously, another function cannot interrupt it on that same stack.

Consider a server callback that parses a request and then runs a large, synchronous calculation. While that calculation occupies the JavaScript thread, other callbacks waiting for that thread cannot run. An event loop does not preempt the calculation or divide it automatically across CPU cores.

“Single-threaded” describes the execution of JavaScript in that agent. It does not mean the entire browser or Node.js process contains only one thread. Runtimes and operating systems can use other threads or system facilities for work such as I/O, rendering, or background tasks.

Call stack, tasks, and the event loop

The call stack tracks the functions currently executing. A function call adds a frame; returning removes it. As long as synchronous code remains on the stack, the event loop cannot run the next scheduled JavaScript callback on that agent.

A task (often informally called a macrotask) is a unit of scheduled work. In a browser, examples include handling an event or running a timer callback. In Node.js, callbacks are handled through its event loop and its phases. The precise scheduling rules differ between environments, so browser ordering details should not be assumed to describe Node.js in every case.

At a high level, the loop waits for runnable work, runs JavaScript when appropriate, and returns to scheduling when that work completes. If the stack is busy with synchronous code, queued callbacks wait.

A simple browser example

console.log("A");

setTimeout(() => {
  console.log("timer callback");
}, 0);

Promise.resolve().then(() => {
  console.log("promise reaction");
});

console.log("B");

In a browser, the synchronous logs appear first: A, then B. The promise reaction is queued as a microtask, while the timer callback is scheduled as a task. After the current JavaScript finishes, the microtask is processed before the timer task in this example. A zero-millisecond timer does not mean “run immediately”; it means the callback becomes eligible after a delay and scheduling rules allow it to run.

Promises and microtasks

A promise represents a value that may be available now, later, or never because of a failure. Calling .then(), .catch(), or .finally() schedules a reaction when the promise settles. Promise reactions are processed as microtasks in browsers and are associated with the runtime's job scheduling in Node.js.

A microtask queue holds work that should run after the current JavaScript stack finishes, before the event loop proceeds to later tasks in common browser scheduling scenarios. Browsers also perform microtask checkpoints at defined points in their event-loop processing. If code continually queues more microtasks, it can delay tasks and rendering; asynchronous scheduling does not guarantee fairness.

Promises do not inherently create threads. For example, Promise.resolve().then(doWork) schedules doWork for later, but doWork still runs synchronously on the same JavaScript thread. If it takes a long time, it blocks that thread just as it would if called directly.

In Node.js, process.nextTick() is another scheduling mechanism, distinct from promise microtasks and the event-loop phases. Excessive recursive use of process.nextTick() can prevent the loop from making progress on other work. Avoid assuming that every queue has identical ordering across browsers and Node.js.

How non-blocking I/O supports many connections

Suppose a server receives a request and needs data from a database or an upstream service. With asynchronous I/O, the JavaScript callback can start the operation and register what should happen when it completes. The runtime and operating system manage the wait. The JavaScript thread can then process other ready callbacks instead of sitting idle inside a blocking read.

When the I/O completes, the relevant callback or promise continuation becomes eligible to run. The event loop runs it when the JavaScript thread is available. This is why one process can keep many connections open: most connections spend much of their time waiting, rather than continuously executing JavaScript.

The details depend on the operation and platform. Node.js uses the operating system's non-blocking mechanisms for much network I/O, while some operations, including certain filesystem, cryptographic, and name-resolution work, may use a worker pool. “Non-blocking” means the calling JavaScript can continue without waiting synchronously for that operation; it does not mean the operation consumes no resources or always runs on a separate thread.

Why connection count is not the same as throughput

Keeping connections open is only part of the workload. Every request still consumes resources: memory, sockets, buffers, and time in application callbacks. A slow dependency can also leave requests pending. A service may need limits, timeouts, backpressure, and connection-pool policies to keep resource use controlled.

Most importantly, long CPU-bound JavaScript callbacks block the event loop. If one callback spends a long time compressing data, processing a large input, or performing a complex calculation, other callbacks on that event loop wait. The event loop helps with waiting; it does not make a CPU-bound callback disappear.

Browser JavaScript, Node.js, and workers

EnvironmentTypical JavaScript executionBackground or parallel work
Browser main threadRuns application code and coordinates with browser event processing; long tasks can delay input and rendering.Web Workers run scripts separately and communicate with the page through messages. A worker does not directly share the page's ordinary JavaScript state.
Node.jsRuns callbacks on its event loop; asynchronous APIs let the process make progress while I/O is pending.Node.js can use worker threads for CPU-intensive JavaScript. Some runtime and operating-system work also happens outside the main JavaScript thread.
Browser or Node.js workerHas its own JavaScript execution context and event-loop behavior.Multiple workers can execute JavaScript concurrently on separate threads, subject to the platform and available resources.

Concurrency means multiple activities can make progress over overlapping periods. Parallelism means work is actually executing at the same time, for example on separate CPU cores. An event loop provides concurrency for scheduled work, but a single JavaScript agent does not execute two callbacks simultaneously. Workers can enable parallel execution, but they add coordination costs and are not a universal replacement for asynchronous I/O.

In Node.js, the worker_threads module is intended for JavaScript that benefits from CPU parallelism. For ordinary network I/O, asynchronous APIs are generally the more direct model. In browsers, Web Workers are useful for work that would otherwise occupy the main thread, but they communicate by messages or supported shared-memory mechanisms rather than freely sharing ordinary objects.

Practical workflow: identify what is blocking

When an application feels slow, first determine whether it is waiting on I/O or spending too long executing JavaScript:

  1. Find the long operation. Use browser performance tools or Node.js profiling and diagnostic tools to identify long callbacks and expensive synchronous work.
  2. Check whether the API is asynchronous. A synchronous filesystem call, for example, can block the JavaScript thread. Prefer the asynchronous API when blocking the event loop is not intended.
  3. Keep callbacks bounded. Break large jobs into manageable chunks when possible, or move substantial CPU-bound work to a worker.
  4. Bound external work. Set suitable timeouts and limits, and avoid starting unlimited requests or accumulating unbounded queues.
  5. Measure under realistic load. Test the behavior of your own application, dependencies, and deployment environment; the event loop alone does not determine capacity.

A promise-based example can make a network wait non-blocking from the caller's perspective, but the promise does not make subsequent CPU work parallel:

async function loadAndSummarize(url) {
  const response = await fetch(url);

  if (!response.ok) {
    throw new Error(`Request failed: ${response.status}`);
  }

  const data = await response.json();
  return summarize(data); // Still runs on this JavaScript thread.
}

This example illustrates control flow, not a complete production networking policy. Production code should consider timeouts, cancellation, response-size limits, error handling, and the trustworthiness of the destination. If a server accepts user-controlled URLs, a URL parser or hostname check alone is not a complete defense against server-side request forgery (SSRF); network access needs appropriate server-side controls as well.

Common mistakes

  • Treating async as a new thread. An async function returns a promise, but its synchronous sections still run on the current JavaScript thread.
  • Assuming a promise makes its work parallel. Promise callbacks are scheduled work. Parallelism requires an operation that uses parallel facilities or code running in a worker.
  • Using a zero-delay timer as an immediate yield guarantee. Timers have scheduling rules and may run later than their requested delay.
  • Blocking the event loop with expensive callbacks. Synchronous CPU work delays other callbacks, even when the rest of the program uses promises.
  • Assuming browser and Node.js scheduling are identical. Both have event loops, but their APIs, queues, and scheduling details differ.
  • Equating many connections with unlimited capacity. Each connection and pending operation has costs, and application code or dependencies can become bottlenecks.

Recommendations

  • Prefer asynchronous I/O APIs when waiting should not block the JavaScript thread.
  • Keep synchronous callbacks short, especially in servers and browser main-thread code.
  • Use browser workers or Node.js worker threads when measured CPU-bound work needs to move off the main JavaScript thread.
  • Apply backpressure and resource limits so incoming work cannot create unbounded queues.
  • Treat concurrency and parallelism as separate design choices, and choose based on whether the bottleneck is waiting or computation.

Conclusion

JavaScript can handle many concurrent connections because asynchronous I/O lets the runtime wait for operations without occupying the JavaScript call stack. When results are ready, the event loop schedules callbacks and promise reactions to run. That model is efficient for I/O-heavy workloads, but it does not make synchronous JavaScript parallel. Understanding the call stack, tasks, microtasks, and worker options helps you choose the right approach—and recognize when CPU work or resource limits are the real bottleneck.

References