Le Quang Lam
← All writing

How JavaScript handles asynchronous work — Browser vs Server

July 15, 2026·8 min read·software

When you open a web tab, the browser does a dozen things: it downloads resources in parallel (the HTML, JS bundle, images,...), fetches data from APIs, animates elements, and stays responsive to user scrolls and clicks — seemingly all at the same time. On the server, a single Node.js process can hold thousands of open connections, without creating a new thread for every request. So how does a language that is single-threaded, with one call stack, running one thing at a time, pull this off in both places?

The part that is identical: writing async code

The trick is the same idea in both worlds: JavaScript itself never waits. It offloads slow work to something outside the language, keeps running, and picks the result back up later through the event loop. What differs is who that "something outside" is, and how the event loop schedules the work that comes back.

Before answering these questions, it's worth seeing that everything about how you write asynchronous code is the same in both environments. JavaScript gives you three patterns, and they build on top of one another.

Callbacks

Pass a function to be run after an operation finishes.

readFile("data.txt", (err, data) => {
  if (err) return handleError(err);
  console.log(data);
});

Callbacks work, but nesting them for sequential async steps produces "callback hell" — deeply indented code with error handling scattered at every level.

Promises

A Promise is an object representing the eventual result of an async operation. It moves from pending to either fulfilled or rejected, and lets you chain steps with .then() and centralize error handling with .catch().

fetchUser(id)
  .then((user) => fetchOrders(user.id))
  .then((orders) => render(orders))
  .catch((err) => handleError(err));

Promises don't replace callbacks — they wrap them into a flatter, composable shape.

async / await

async/await is syntactic sugar built on Promises. It is not a new mechanism. An async function always returns a Promise, and await simply pauses until a Promise settles, letting asynchronous code read like synchronous code.

async function load(id) {
  try {
    const user = await fetchUser(id);
    const orders = await fetchOrders(user.id);
    render(orders);
  } catch (err) {
    handleError(err);
  }
}

Where the runtimes split

JavaScript on its own has no setTimeout, no document, no file system access, and no process. Those come from the host environment. This is the first real fork between browser and server.

In the browser, the JavaScript engine (V8 in Chrome) runs your code, and the browser supplies Web APIs: setTimeout, DOM events, fetch, requestAnimationFrame, and more. When you call one of these, the waiting happens outside the JavaScript engine — not on your thread.

When it finishes, the callback is queued, and the event loop pushes it onto the call stack once the stack is empty.

In Node.js, V8 runs your code, but the async machinery comes from libuv — a C library that provides the event loop plus two ways of handling slow work:

  • A Worker Pool (libuv's thread pool, 4 threads by default) handles operations like file system access (fs), DNS lookups (dns.lookup), and some crypto (pbkdf2, scrypt). These are genuinely blocking at the OS level, so libuv runs them on separate threads.
  • Network I/O (TCP/HTTP sockets) does not use the thread pool. It relies on the operating system's own non-blocking mechanisms — epoll on Linux, kqueue on macOS, IOCP on Windows. This is the real reason Node scales so well for servers: thousands of connections, no thread per connection.

So when your code hits a blocking operation, Node.js doesn't wait. It hands the work to the Worker Pool or the kernel, the main JavaScript thread keeps executing, and when the work finishes, a callback is queued back to the event loop to run on the single JS thread.

The event loop: same purpose, different scheduling

The event loop is the mechanism that lets a single JavaScript thread perform non-blocking async work. It moves async callbacks and microtasks onto the call stack once your synchronous code has finished running.

Both environments share one crucial rule about two tiers of queued work:

  • Macrotasks — timers, I/O callbacks, setImmediate (Node only). One is taken per loop turn.
  • Microtasks — Promise callbacks (.then/.catch/.finally). The microtask queue is drained completely before the loop moves on to the next macrotask.

That ordering — finish all microtasks before the next macrotask — holds in both the browser and Node. But the structure around it differs.

The browser event loop

The browser model is comparatively simple. It has a task queue (macrotasks) and a microtask queue. Each turn: run one macrotask, drain all microtasks, then — critically — the browser may render (recalculate styles, layout, paint) before the next task. It doesn't render every turn, only in step with the screen's refresh rate.

The visualization above shows the priority between the two queues — one macrotask, then all microtasks — but it leaves out the render step. The event loop itself is fast — a single turn usually finishes in under a millisecond. Most screens, meanwhile, only refresh about 60 times a second, roughly once every 16.6ms, so many turns can happen between two frames.

So the browser runs turn after turn back-to-back, and only squeezes in a render when the next frame is due. Many tasks can come and go between two paints. This is also why blocking one turn for too long can make the browser miss a frame.

Drag the slider to add sync work to each frame. Once it goes past 16.6ms, the call stack is still busy when the next paint is due — the box stutters, clicks lag, FPS drops. The frame was ready to render; the event loop just never got there.

The Node.js event loop

Node.js splits the event loop into 6 phases, each with its own queue, executed in a fixed order every iteration. Of these, Timers, Poll, and Check are the ones you'll actually reason about — the rest are mostly internal.

  1. Timers — runs callbacks scheduled by setTimeout() and setInterval(). The delay is the minimum time before the callback is eligible to run, not a guarantee — even setTimeout(fn, 0) has to wait for this phase.
  2. Pending Callbacks — Executes I/O callbacks deferred from the previous loop iteration (e.g., certain system error callbacks). Rarely relevant to your code.
  3. Idle, Prepare — internal to Node.
  4. Poll — retrieves new I/O events (file system, network, etc.) and runs their callbacks. This is where most of your async code actually executes. If the poll queue is empty:
    • if any setImmediate() callbacks are pending → jump to Check;
    • else if a timer has expired → return to Timers next iteration;
    • else → block and wait for new I/O.
  5. Check — runs setImmediate() callbacks. Designed to fire right after the Poll phase, which is what makes it different from setTimeout(fn, 0).
  6. Close Callbacks — runs close handlers like socket.on('close').

The queues that run between phases

On top of the six phases, Node has two queues that run outside the phases, in this priority order:

  1. process.nextTick() queue — a Node-specific mechanism, sitting above everything else. Not part of any spec.
  2. Microtask queue — the same Promise queue you already know from the browser (.then/.catch/.finally, queueMicrotask()), managed by V8.

After every callback execution, and after each phase, Node drains both — first all nextTick callbacks, then all microtasks. Only when both are empty does the loop continue to the next phase. That's why a chain of Promises resolves fully before the loop advances.

Conclusion

Underneath everything, JavaScript's async model comes down to one rule: never block the main thread. In both environments, the language hands slow work to the host and picks the result back up through the event loop.

What differs is what each environment is built to do well:

AspectBrowserNode.js
Optimized forA responsive UI — smooth scrolling, instant reaction to clicks and inputServer throughput — thousands of open connections, fast disk and network I/O
Async providerWeb APIs supplied by the browserlibuv — a Worker Pool for blocking work, and the OS kernel for network I/O
Loop behaviorOne task, then all microtasks, with a render step slotted in for the next frameSix fixed phases per iteration, each draining its own queue

References