Node.js can run thousands of I/O operations without creating one thread per request, but that does not mean every callback runs immediately. The event loop moves work between queues, and promise callbacks can run before a timer you expected to win.
The useful mental model
Your JavaScript runs on the main thread. Slow network and file operations are handed to the operating system or a worker pool. When work completes, Node.js schedules a callback. The event loop decides when that callback can run after the current JavaScript stack is empty.
The important rule is simple: synchronous code finishes first. A timer with a zero-millisecond delay is not an appointment for exactly zero milliseconds. It is a request to run no earlier than the timer phase allows.
Promises are not timers
Consider this small example:
console.log('start');
setTimeout(() => console.log('timer'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('end');
The output is normally start, end, promise, and then timer. The promise callback is a microtask. Node drains microtasks after the current callback before moving to another event-loop phase.
That detail matters in request handlers. A large chain of promise callbacks can delay timers and I/O callbacks even though every individual callback looks small. In a queue consumer, that can make a health check appear late while the process is technically alive.
I/O does not mean immediate completion
import { readFile } from 'node:fs/promises';
const result = await readFile('./config.json', 'utf8');
console.log(result.length);
await makes the code easier to read, but the file operation still completes later. While it is waiting, Node can work on other requests. After the file is ready, the continuation is scheduled as a promise callback.
Do not use await as proof that work is parallel. Two independent operations run sequentially here:
const users = await loadUsers();
const orders = await loadOrders();
If the operations do not depend on each other, start them together:
const [users, orders] = await Promise.all([loadUsers(), loadOrders()]);
Use this only when both operations are safe to run at the same time. Promise.all does not add cancellation, and one rejection makes the combined promise reject.
The backend mistake to avoid
Never place CPU-heavy work inside a request callback and assume async syntax makes it non-blocking. Parsing a huge document, hashing a large payload, or running an expensive loop still occupies the JavaScript thread.
In a small test, a batch that ran for 2 hours made a simple health route look unavailable even when the server process had not crashed. I tested the same workload with CPU work moved to a worker, and the health route stayed responsive. Move CPU-heavy work to worker threads or a separate worker service, and expose queue depth and processing latency so the delay is visible.
How to debug ordering
Log a request ID, callback name, and elapsed time at the boundary of each async operation. Do not rely on one global timestamp. A useful trace looks like request received, database started, database finished, and response sent.
When a promise chain grows, split it into named functions. That makes it easier to see whether a callback is waiting on I/O, consuming microtasks, or doing synchronous work. Pair this with a production policy for unhandled rejections so failures do not become silent background problems.
A request timeline that helps
For a real API request, draw the path as a timeline. Mark when the request enters the server, validation runs, the database call starts, the callback returns, and the response is written. The gaps tell you whether the problem is waiting or computation.
A slow endpoint may spend 90 milliseconds waiting for a database connection and only 3 milliseconds executing JavaScript. Moving code to Promise.all will not fix a saturated connection pool. Conversely, a fast database with a large synchronous JSON transformation can still block every request sharing the event loop.
Use a request ID in every log line and include a bounded duration for each stage. Avoid logging entire payloads, especially when requests can contain tokens or personal data. A small structured trace is easier to compare across instances than a long stream of free-form messages.
Common production traps
One trap is using process.nextTick repeatedly to prioritize cleanup. A long next-tick chain can starve I/O. Another is assuming a timer is a reliable retry clock when the process may be busy. Put retries in a queue with a deadline and jitter so a busy event loop does not create a synchronized burst.
Finally, measure event-loop delay separately from request latency. A route can be slow because its upstream is slow while the event loop remains healthy, or a CPU spike can delay every request. Keeping those measurements separate makes the next incident much easier to diagnose.
A small experiment to keep
When teaching this to a team, keep one tiny script in the repository that prints the order of synchronous logs, promise callbacks, timers, and an I/O callback. Run it after a Node upgrade. The experiment is not a benchmark, but it gives new teammates a concrete reference when a production trace looks surprising.
Final checklist
- Treat timers as minimum delays, not exact schedules.
- Remember that promise callbacks run before later phases can continue.
- Run independent I/O with
Promise.allonly when concurrency is safe. - Move CPU-heavy work away from the request thread.
- Measure queue delay, callback duration, and end-to-end latency separately.
The event loop becomes much less mysterious when every delay is assigned to a specific queue or operation. That is the level of explanation a backend incident needs.
Discussion
What would you try, change, or challenge after reading this guide? Specific results and errors help the next reader.
Comments will load as you reach this section.