Skip to main content
David Dew Mallick

David Dew Mallick

ProjectsExperienceSkillsBlog
Back to blog
javascriptevent loopasyncperformance

JavaScript Event Loop: Blocking vs Non-Blocking

How the JavaScript event loop turns single-threaded code into non-blocking I/O, where microtasks and macrotasks differ, and the exact patterns that still

Oct 3, 2026 · 6 min read

JavaScript runs on a single thread, yet it handles thousands of concurrent connections without blocking. That only works if you understand what the javascript event loop actually does with your code: what runs immediately, what waits for a promise, what waits for a timer, and what quietly starves everything else. Get the ordering wrong and a "non-blocking" API endpoint still freezes for hundreds of milliseconds per request. This guide walks through the loop's real mechanics with runnable examples, then shows the exact failure modes you will hit in production Node and browser code.

Key takeaway

The event loop drains all microtasks (promise callbacks, queueMicrotask) after every macrotask (timers, I/O, events) and before rendering. Any synchronous CPU work blocks the entire loop, so "async" only helps if the blocking work is actually offloaded to the thread pool or another process.

How the event loop actually schedules work

Every JavaScript engine pairs a call stack with an event loop. When you call a function, it runs on the stack until it returns. When you call something non-blocking, like setTimeout or a network request, the engine hands the work to a background thread (Node's libuv thread pool or the browser's network stack) and returns immediately. When that work finishes, a callback is queued. The event loop is the mechanism that repeatedly checks: is the stack empty? If yes, is there a microtask queue? If yes, run all of them. Then run one macrotask, then drain microtasks again, then render (in browsers).

console.log('1: sync start');

setTimeout(() => console.log('4: setTimeout'), 0);

Promise.resolve().then(() => console.log('3: microtask'));

queueMicrotask(() => console.log('3b: queueMicrotask'));

console.log('2: sync end');

// Output:
// 1: sync start
// 2: sync end
// 3: microtask
// 3b: queueMicrotask
// 4: setTimeout

The ordering is not arbitrary. Synchronous code runs first because it is already on the stack. Microtasks run before macrotasks because the engine drains the microtask queue completely after each macrotask, and setTimeout(fn, 0) is still a macrotask that waits for the timer phase. Run this snippet in any Node version or browser console to verify the ordering yourself.

Microtasks vs macrotasks: the practical difference

Task typeExamplesWhen it runsCan starve rendering?
MicrotaskPromise.then, queueMicrotask, MutationObserverImmediately after current macrotask, before next macrotaskYes, if microtasks enqueue more microtasks indefinitely
MacrotasksetTimeout, setInterval, I/O callbacks, setImmediate, UI eventsOne per loop iteration, after microtask drainYes, if the task itself is slow

Why async/await does not make CPU work non-blocking

async/await is syntactic sugar over promises. It makes I/O non-blocking by suspending the function until the promise resolves, but it does not move synchronous CPU work off the main thread. A tight loop that takes 200ms blocks the event loop for 200ms regardless of whether it is inside an async function. This is the single most common misconception about the javascript event loop in Node services.

// This still blocks the event loop for the duration of the loop.
async function processItems(items) {
    let total = 0;
    for (const item of items) {
        total += heavyComputation(item); // CPU-bound, blocks the loop
    }
    return total;
}

// Offload CPU work to a worker thread instead.
import { Worker } from 'node:worker_threads';

function runInWorker(items) {
    return new Promise((resolve, reject) => {
        const worker = new Worker('./heavy-worker.js', { workerData: items });
        worker.on('message', resolve);
        worker.on('error', reject);
    });
}

If your Node process serves HTTP requests while doing CPU-heavy work (image processing, large JSON transforms, CSV parsing), the event loop stalls and every concurrent request waits. Move that work to worker_threads, a child process, or a separate queue consumer. In a Laravel app, this is the same reason queued jobs exist: the HTTP request returns, and the heavy work runs in a separate worker process that can be scaled independently. See Laravel Queue Best Practices for Reliable Background Jobs for the queue-side equivalent.

Where the event loop bites in real applications

Unbounded microtask chains

A promise that resolves with another promise, recursively, can starve the loop forever. The engine drains microtasks until the queue is empty, so an infinite chain means no macrotask ever runs: no timers, no I/O callbacks, no rendering. In a browser, the tab freezes. In Node, the process stops handling requests.

function recurse() {
    Promise.resolve().then(recurse);
}
recurse(); // Event loop is now permanently starved.

Synchronous JSON or regex on large payloads

JSON.parse on a 50MB payload and catastrophic regex backtracking both run synchronously. Neither is "async" in any meaningful sense. For large payloads, stream-parse instead of buffering the whole body, and benchmark your regexes against adversarial input before shipping them.

setInterval drift and overlap

setInterval(fn, 100) schedules the next tick 100ms after the previous tick started, not after it finished. If fn takes 150ms, ticks queue up and can overlap. Use a self-scheduling setTimeout chain when the work duration is variable:

function poll() {
    doWork()
        .catch(() => {})
        .finally(() => setTimeout(poll, 100)); // next tick only after work finishes
}
poll();

Debugging the event loop when something feels slow

  1. Measure event loop lag directly. In Node, perf_hooks.monitorEventLoopDelay() gives you histogram data. In the browser, Performance panel recordings show long tasks.
  2. Identify whether the stall is one long synchronous task or a microtask storm. Long tasks appear as single wide bars; microtask starvation appears as continuous work with no idle gaps.
  3. Check for accidental synchronous I/O. fs.readFileSync, crypto.pbkdf2Sync, and synchronous DNS lookups all block the loop. Replace them with their async counterparts.
  4. Profile with --cpu-prof in Node to find the hottest synchronous function, then decide whether it belongs on the main thread at all.

If you are running Laravel behind FrankenPHP or Octane, the same reasoning applies at the process level: a single slow request handler blocks that worker's event loop, and Octane's long-lived process model means any static state you leak between requests compounds. See Laravel Octane on PHP 8.5: What Changes for the state-management pitfalls.

Frequently asked questions

Does await yield to the event loop?

await suspends the async function and lets the current synchronous execution finish, but it does not necessarily yield to the event loop. If the awaited promise is already resolved, execution resumes in a microtask, which still runs before any macrotask. To force a macrotask boundary, await a setTimeout-wrapped promise or use setImmediate.

How many microtasks can run before the loop processes a timer?

All of them. The engine drains the microtask queue completely after every macrotask, including newly enqueued microtasks, before moving to the next macrotask. A chain of a million resolved promises will all run before a setTimeout(fn, 0) fires.

Is the javascript event loop the same in browsers and Node?

The core model is the same: call stack, microtask queue, macrotask queue, loop. The differences are in the macrotask sources. Browsers add rendering and input events; Node adds phases like timers, I/O callbacks, and setImmediate. The microtask-drain rule is identical in both.

DD

David Dew Mallick

Software Engineer

I build AI-driven SaaS infrastructure and backend systems with Laravel, AWS, and SQL, and write about the engineering decisions behind them.

GitHubLinkedInEmail

More posts

  • Oct 5, 2026 · 7 min read

    JavaScript Async Await: Patterns and Pitfalls

    A practical guide to JavaScript async await: how it maps to the event loop, sequential versus parallel execution, error handling, and the mistakes that cause

  • Oct 1, 2026 · 7 min read

    Laravel Best Practices for Production PHP Apps

    A practical Laravel best practices guide covering service container bindings, Eloquent query discipline, queued events, cache design, and testing seams for

On this page

  • How the event loop actually schedules work
  • Microtasks vs macrotasks: the practical difference
  • Why async/await does not make CPU work non-blocking
  • Where the event loop bites in real applications
  • Unbounded microtask chains
  • Synchronous JSON or regex on large payloads
  • setInterval drift and overlap
  • Debugging the event loop when something feels slow
  • Frequently asked questions
  • Does await yield to the event loop?
  • How many microtasks can run before the loop processes a timer?
  • Is the javascript event loop the same in browsers and Node?
Back to top

End of record

Back to top↑

David Dew Mallick

Dhaka, Bangladesh

Contact

  • david.dew.mallick@g.bracu.ac.bd
  • GitHub
  • LinkedIn

2026 David Dew Mallick

  • Privacy
  • Terms
  • Cookies