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.

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 setTimeout
, fetch
, 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 afterawait
)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:
- Run synchronous code (top-level script).
- Flush all microtasks (in order).
- Render (browser only) if needed.
- Execute one macrotask from the queue.
- 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.then
,queueMicrotask
) 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?
Type | Task | When it Executes |
---|---|---|
Sync | console.log("Start") | Immediately |
Microtask | Promise.then() | Right after sync |
Microtask | await Promise.resolve() | After .then() |
Macrotask | setTimeout(..., 0) | After microtasks |
Macrotask | fs.readFile(..., cb) | After timeout |
Explanation:
Promise.then
andawait
go into the microtask queue.setTimeout
andfs.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 beforesetImmediate
. - 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
andsetImmediate
are inside an I/O callback,setImmediate
is executed first.
🧰 When to Use What?
Use Case | Prefer |
---|---|
High-priority short callback | Promise.then , await |
Scheduled delay or debouncing | setTimeout |
I/O-heavy operations (Node.js) | Async callbacks (macrotask) |
Trigger logic after render (DOM) | requestAnimationFrame |
Manual microtask control | queueMicrotask |
⚠️ 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:
Promise.then()
executes beforesetTimeout()
, even if both are added at the same time.- Microtasks can starve macrotasks if they are continuously added in a loop.
async/await
under the hood uses Promises, so the code afterawait
goes into the microtask queue.
Final Thoughts
Understanding how JavaScript concurrency works — particularly the event loop, microtasks, and macrotasks — will dramatically improve your ability to write performant, non-blocking code.
Key takeaways:
- Use microtasks for high-priority logic (
Promise.then
,await
). - Use macrotasks for timing, I/O, or scheduling (
setTimeout
, I/O). - Always test and visualize execution order using simple logs.