JavaScript Concurrency — How the Event Loop Handles Microtasks (Promises) and Macrotasks

Node.js — understanding concurrency, the event loop, and the difference between microtasks and macrotasks is essential. If you've ever been confused why Promise.then() runs before setTimeout(), or why some I/O callbacks appear “delayed,” this guide will clear things up.

JavaScript Concurrency — How the Event Loop Handles Microtasks (Promises) and Macrotasks
Photo by Kelly Sikkema / Unsplash

In modern JavaScript applications — especially those that run in browsers or on Node.js — understanding concurrency, the event loop, and the difference between microtasks and macrotasks is essential. If you've ever been confused why Promise.then() runs before setTimeout(), or why some I/O callbacks appear “delayed,” this guide will clear things up.


What Is the JavaScript Event Loop?

JavaScript is single-threaded, meaning it can execute one task at a time. But thanks to asynchronous APIs like setTimeoutfetch, or fs.readFile, JavaScript can schedule tasks to run later — without blocking the main thread.

The event loop is the mechanism that JavaScript uses to manage task execution — deciding what to run next, in which order, and when.


🔄 Event Loop Priority

┌─────────────────────────────────────────┐
│ Current Script Execution │ ← Executes first
├─────────────────────────────────────────┤
│ Microtasks Queue │ ← Executes next
├─────────────────────────────────────────┤
│ Macrotasks Queue │ ← Executes after microtasks
└─────────────────────────────────────────┘

Task Types: Microtasks vs Macrotasks

JavaScript splits asynchronous tasks into two main queues:

✅ Microtasks

These are high-priority tasks that run after the current operation completes but before any rendering or I/O callbacks.

Examples:

  • Promise.then()catch()finally()
  • async/await (code after await)
  • queueMicrotask()
  • MutationObserver

Execution Rule:

Microtasks run after the current script, and before any macrotask.

✅ Macrotasks

These are lower-priority tasks scheduled to run after microtasks are cleared.

Examples:

  • setTimeout()
  • setInterval()
  • setImmediate() (Node.js)
  • I/O callbacks (e.g. fs.readFile)
  • postMessage / MessageChannel
  • requestAnimationFrame() (browser)

🔁 How the Event Loop Works

Each tick of the event loop does the following:

  1. Run synchronous code (top-level script).
  2. Flush all microtasks (in order).
  3. Render (browser only) if needed.
  4. Execute one macrotask from the queue.
  5. Repeat.

Let’s See It in Action

Example 1: Basic Execution Order

console.log("Script start");

setTimeout(() => {
  console.log("Macrotask: setTimeout");
}, 0);

Promise.resolve().then(() => {
  console.log("Microtask: Promise.then");
});

queueMicrotask(() => {
  console.log("Microtask: queueMicrotask");
});

console.log("Script end");

🟩 Output:

Script start
Script end
Microtask: Promise.then
Microtask: queueMicrotask
Macrotask: setTimeout

Why?

  • Synchronous code runs first (console.log).
  • All microtasks (Promise.thenqueueMicrotask) run next.
  • Finally, the macrotask from setTimeout.

Example 2: Mixing with I/O (Node.js)

const fs = require("fs");

console.log("Start");

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

fs.readFile(__filename, () => {
  console.log("fs.readFile callback");
});

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

(async function () {
  await Promise.resolve();
  console.log("Async/Await");
})();

🟩 Possible Output:

Start
Promise then
Async/Await
Timeout callback
fs.readFile callback

Why This Order?

TypeTaskWhen it Executes
Syncconsole.log("Start")Immediately
MicrotaskPromise.then()Right after sync
Microtaskawait Promise.resolve()After .then()
MacrotasksetTimeout(..., 0)After microtasks
Macrotaskfs.readFile(..., cb)After timeout

Explanation:

  • Promise.then and await go into the microtask queue.
  • setTimeout and fs.readFile go into the macrotask queue.
  • The event loop prioritizes microtasks after the main script finishes.

Example 3: setImmediate vs setTimeout

setImmediate(() => {
  console.log('setImmediate');
});

setTimeout(() => {
  console.log('setTimeout');
}, 0);

🟩 Possible Output:

Output may vary slightly, but generally:
setTimeout
setImmediate
Why?
  • In most cases, setTimeout with 0ms runs before setImmediate.
  • But if inside an I/O callback, setImmediate runs first.

Example 4: Inside I/O callback

const fs = require('fs');

fs.readFile(__filename, () => {
  setImmediate(() => {
    console.log('setImmediate inside fs');
  });

  setTimeout(() => {
    console.log('setTimeout inside fs');
  });
});

🟩 Possible Output:

setImmediate inside fs
setTimeout inside fs
Why?
  • When both setTimeout and setImmediate are inside an I/O callback, setImmediate is executed first.

🧰 When to Use What?

Use CasePrefer
High-priority short callbackPromise.then, await
Scheduled delay or debouncingsetTimeout
I/O-heavy operations (Node.js)Async callbacks (macrotask)
Trigger logic after render (DOM)requestAnimationFrame
Manual microtask controlqueueMicrotask

⚠️ Remember

  • Microtasks can starve macrotasks if you keep chaining Promises in a loop.
  • async/await is just syntactic sugar over Promises — it's still a microtask.
  • Microtasks can be batched, but macrotasks are handled one per tick.

🚀 Real-World Use Cases

Here’s how developers leverage micro/macrotasks:

Middleware in Node.js

Besides auth, middlewares are used for:

  • Logging
  • Validation
  • Parsing requests
  • Compressing responses
  • Error handling

Sending a Response after Async Tasks

app.get('/', (req, res) => {
  Promise.resolve().then(() => {
    console.log('Microtask: Promise');
  });

  setTimeout(() => {
    console.log('Macrotask: Timeout');
  }, 0);

  res.send("Hello!");
});
Response is sent immediately, then microtasks, then macrotasks.

Graceful Shutdown

Use this with streaming, DB connections:

process.on('SIGINT', async () => {
  console.log('Shutting down...');
  await db.disconnect();
  server.close(() => process.exit(0));
});

Streaming Files to S3 (Node.js)

Using streams in Node.js helps avoid memory overload because they process data in chunks instead of loading the entire data into memory at once. This is especially useful when working with large files (like logs, videos, or backups) or network responses.

Uploading a 1GB video to S3 (Real-world example)

const fs = require('fs');
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
const stream = fs.createReadStream('./video.mp4');

const client = new S3Client({ region: 'us-east-1' });
const upload = new PutObjectCommand({
  Bucket: 'your-bucket',
  Key: 'video.mp4',
  Body: stream
});
await client.send(upload);

Why streams help:

  • 🧠 Lower memory usage: Only small portions of data are in memory at any time.
  • ⚡ Non-blocking I/O: While a chunk is being processed, Node can continue other tasks.
  • 🚫 Avoids crashes: Prevents your app from hitting memory limits with large payloads.

⚠️ Some Gotchas:

  1. Promise.then() executes before setTimeout(), even if both are added at the same time.
  2. Microtasks can starve macrotasks if they are continuously added in a loop.
  3. async/await under the hood uses Promises, so the code after await goes into the microtask queue.

Final Thoughts

Understanding how JavaScript concurrency works — particularly the event loopmicrotasks, and macrotasks — will dramatically improve your ability to write performant, non-blocking code.

Key takeaways:

  • Use microtasks for high-priority logic (Promise.thenawait).
  • Use macrotasks for timing, I/O, or scheduling (setTimeout, I/O).
  • Always test and visualize execution order using simple logs.