Event loop lag in Node.js: why an API is slow even when the database is fine
Event loop lag helps explain latency that is not caused by a slow database: CPU-bound code, heavy serialization, sync APIs and long microtask chains.
When a backend becomes slow, the first assumption is usually familiar: the database is slow, the network is unstable, Redis is overloaded, or an external service takes too long. Sometimes that is true. But in Node.js there is another important source of latency: event loop lag.
Event loop lag happens when the main Node.js thread is busy and cannot move to the next task on time. Requests have arrived, timers are ready, socket events are waiting, but JavaScript is still executing synchronous code.
Why It Hurts
Node.js is often used for I/O-heavy backends: REST APIs, GraphQL, WebSocket, queues, integrations and database access. This model works well: while the database or network is doing work, the event loop can handle other events.
But if heavy synchronous work appears inside a request handler, the whole process becomes less responsive. One user can trigger processing that makes other requests wait.
Typical symptoms:
- p95/p99 latency grows without an obvious reason;
- the database responds quickly, but the API is still slow;
- WebSocket events arrive late;
- timers and scheduled jobs execute later than expected;
- healthchecks sometimes respond slowly even though the service is alive;
- process CPU is high while I/O does not look overloaded.
Common Causes
Frequent sources of event loop lag in backend code:
- large
JSON.stringifyorJSON.parse; - processing large arrays synchronously;
- generating Excel/PDF/documents on the main thread;
- synchronous filesystem operations;
- heavy cryptography without workers;
- image processing;
- problematic regular expressions;
- very long Promise/microtask chains;
- logging huge objects.
The code may look harmless:
const rows = await this.repository.findMany(filters);
const enriched = rows.map((row) => heavyTransform(row));
return { items: enriched };
The problem is not await. Once data arrives from the database, map runs synchronously. If rows is large, the event loop is busy until transformation finishes.
How I Think About It
I try to separate I/O from CPU work. I/O can benefit from Node.js asynchronous model. CPU work needs limits, isolation or chunking.
Practical questions:
- how many items can this endpoint return;
- is pagination strictly limited;
- can transformation move to SQL;
- can the response be streamed;
- should generation move to a queue;
- should heavy work use worker threads;
- what happens if 20 users trigger this flow at the same time.
If the answer is “the process will just calculate it”, that is often a risky answer for a Node.js backend.
A Queue Does Not Always Solve It
Moving work to BullMQ or another queue is useful, but it is important to remember: if a worker runs in the same Node.js process and performs heavy synchronous work, it can block its own event loop too.
A queue helps with:
- removing long work from the user-facing HTTP request;
- retries;
- concurrency control;
- process status;
- error logging.
But CPU-heavy tasks may still need a separate worker process, worker threads or another service. A queue organizes execution; it does not automatically make computation faster.
How to Monitor It
Node.js can measure event loop delay through perf_hooks:
import { monitorEventLoopDelay } from 'node:perf_hooks';
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();
setInterval(() => {
const p95 = histogram.percentile(95) / 1_000_000;
console.log({ eventLoopDelayP95Ms: p95 });
histogram.reset();
}, 10_000);
This does not replace full observability, but it gives a useful signal: if event loop delay grows together with latency, look for blocking code, not only slow SQL queries.
What I Usually Do
My basic checklist:
- enforce limits on list and batch operations;
- avoid synchronous filesystem APIs in request paths;
- do not log huge payloads in full;
- move document/report generation to background jobs;
- control worker concurrency;
- consider worker threads or a separate service for CPU-heavy tasks;
- check regular expressions and large JSON operations;
- track latency and event loop delay metrics.
Takeaway
Event loop lag is a reminder that Node.js is asynchronous, not magical. It handles lots of I/O well, but heavy synchronous work on the main thread hurts it.
For a backend engineer, knowing event loop phases is not enough. The practical value is recognizing their production consequences: rising latency, late timers, delayed WebSocket events and request handlers blocking each other.