惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

量子位
云风的 BLOG
云风的 BLOG
小众软件
小众软件
IT之家
IT之家
T
Tailwind CSS Blog
WordPress大学
WordPress大学
S
SegmentFault 最新的问题
美团技术团队
博客园 - 叶小钗
V
V2EX
博客园 - Franky
大猫的无限游戏
大猫的无限游戏
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
阮一峰的网络日志
阮一峰的网络日志
博客园 - 【当耐特】
罗磊的独立博客
博客园_首页
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
爱范儿
爱范儿
宝玉的分享
宝玉的分享
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Jina AI
Jina AI
月光博客
月光博客
有赞技术团队
有赞技术团队

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution Node.js Cluster Mode: Scaling the Event Loop Across CPU Cores
Node.js Event Loop Lag: The Hidden Metric Behind Random L...
The Practica · 2026-05-20 · via The Practical Developer

Your latency histogram looks like a sawtooth. For two minutes, every request finishes in 40 ms. Then a spike hits 800 ms. CPU is flat. Memory is flat. Garbage collection pauses are under 20 ms. Database query logs show nothing unusual. The APM trace for the slow request shows every downstream call completed in 5 ms, yet the total response time is ten times higher.

The missing metric is event loop lag. Something is blocking the single thread that runs your JavaScript, and every request that arrives during the block sits in the event loop queue waiting its turn. APM tools measure I/O, not CPU blocking. They see a database call that took 5 ms, but they do not see that the call started 500 ms late because the event loop was busy.

This post is the production-grade event loop lag monitor, the five common blockers that show up in real codebases, and the fixes that actually work.

What event loop lag actually means

Node.js uses a single event loop to run all JavaScript. When a request arrives, the HTTP listener pushes a callback onto the loop. When the database responds, another callback joins the queue. The loop executes callbacks in order. If one callback runs for 500 ms, every other callback waits.

Event loop lag is the gap between when a timer or I/O event was supposed to fire and when the loop actually got around to it. A setInterval scheduled every second will fire at 1.0 s, 2.0 s, 3.0 s. If a blocking task runs for 300 ms, the next interval fires at 2.3 s instead of 2.0 s. The lag is 300 ms.

In an API server, that lag is not just a timer delay. It is the time a new HTTP request sits in the kernel queue before Node.js reads it. It is the time an in-flight database response waits before your await resumes. It is invisible to APM because APM measures the async work, not the queue time in front of it.

A production event loop lag monitor

You do not need DataDog or New Relic to measure this. The built-in perf_hooks module gives you nanosecond precision. The pattern is simple: record the expected time of a recurring timer, compare it to the actual time, and the difference is the lag.

Here is the monitor we run in every service. It tracks a rolling histogram, exposes the lag on a health endpoint, and logs a structured warning when lag crosses a threshold.

import { performance } from 'node:perf_hooks';
import http from 'node:http';

class EventLoopMonitor {
  constructor(options = {}) {
    this.intervalMs = options.intervalMs || 1000;
    this.maxHistory = options.maxHistory || 60;
    this.thresholdMs = options.thresholdMs || 100;
    this.lags = [];
    this.last = performance.now();
    this._start();
  }

  _start() {
    this.timer = setInterval(() => {
      const now = performance.now();
      const lag = Math.max(0, now - this.last - this.intervalMs);
      this.last = now;
      this.lags.push(lag);
      if (this.lags.length > this.maxHistory) this.lags.shift();

      if (lag > this.thresholdMs) {
        console.log(JSON.stringify({
          event: 'event_loop_lag_spike',
          lagMs: Math.round(lag * 100) / 100,
          thresholdMs: this.thresholdMs,
          timestamp: new Date().toISOString()
        }));
      }
    }, this.intervalMs);
    this.timer.unref();
  }

  stats() {
    if (this.lags.length === 0) return { p50: 0, p95: 0, p99: 0 };
    const sorted = [...this.lags].sort((a, b) => a - b);
    const p50 = sorted[Math.floor(sorted.length * 0.5)];
    const p95 = sorted[Math.floor(sorted.length * 0.95)];
    const p99 = sorted[Math.floor(sorted.length * 0.99)];
    return {
      p50: Math.round(p50 * 100) / 100,
      p95: Math.round(p95 * 100) / 100,
      p99: Math.round(p99 * 100) / 100,
      samples: this.lags.length
    };
  }

  destroy() {
    clearInterval(this.timer);
  }
}

const monitor = new EventLoopMonitor({
  intervalMs: 1000,
  maxHistory: 60,
  thresholdMs: 100
});

const server = http.createServer((req, res) => {
  if (req.url === '/health') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ status: 'ok', eventLoop: monitor.stats() }));
    return;
  }
  res.writeHead(200);
  res.end('ok');
});

server.listen(3000, () => {
  console.log('Server listening on port 3000');
});

Key details in this implementation:

  • unref() on the timer. The interval keeps running, but it will not hold the process open if the main server shuts down. This prevents the monitor from keeping a zombie process alive during graceful shutdown.
  • Fixed interval timing. We do not reset the expected deadline on every callback. We track this.last and expect it to advance by exactly intervalMs. This means cumulative drift is still measured. If one 300 ms blocker delays the timer, the next interval is still measured from the original cadence, so you see the spike exactly once.
  • Structured JSON logging. When lag crosses the threshold, we emit a single JSON line. Feed this to your log aggregator and alert on event_loop_lag_spike in the last five minutes.
  • Rolling histogram. The stats endpoint gives you p50, p95, p99, and sample count. Poll it from your metrics collector or expose it as a Prometheus gauge.

The five culprits that show up in real code

Once you have the monitor running, you will see lag spikes. Finding the exact function that blocked the loop is the next step. These are the five blockers we have found in production, ranked by frequency.

1. Synchronous JSON parsing on large payloads

A 10 MB JSON payload takes 50-200 ms to parse on a fast CPU. A 100 MB payload can take two seconds. JSON.parse is implemented in C++ inside V8, but it runs on the main thread, and the event loop is frozen until it returns.

The fix is not always “use worker threads.” If you control the producer, stream the data instead of buffering it into a single string. If you do not control the producer, add a hard payload size limit at the edge:

server.on('request', (req, res) => {
  const maxBytes = 1024 * 1024; // 1 MB
  let received = 0;
  req.on('data', (chunk) => {
    received += chunk.length;
    if (received > maxBytes) {
      res.writeHead(413);
      res.end('Payload too large');
      req.destroy();
    }
  });
});

For payloads that genuinely need parsing and are too large to hold in memory, use a streaming JSON parser like stream-json. It yields objects as they arrive, and each chunk of parsing work is a small slice that gives the event loop a chance to breathe between tokens.

2. Synchronous file system calls in the hot path

fs.readFileSync, fs.writeFileSync, and fs.existsSync block the thread until the kernel returns. In a request handler, a single existsSync is only a few milliseconds on a warm page cache, but on an overloaded container with throttled EBS, it can take hundreds of milliseconds.

The rule is simple: no *Sync methods inside route handlers. Move them to boot time, or replace them with the promise-based API:

// Before
const data = fs.readFileSync('./template.html', 'utf8');

// After
const data = await fs.promises.readFile('./template.html', 'utf8');

One exception: configuration reads at startup are fine. The problem is per-request sync I/O.

3. Heavy computation in array methods

A route that filters, maps, and sorts 100,000 objects sounds fast in local testing. On a warmed CPU, it takes 20 ms. On a throttled cloud instance with noisy neighbors, it takes 200 ms. And because it is JavaScript, not I/O, APM shows the function as “self time” without telling you it blocked every other request.

// Dangerous in a request handler if items is large
const result = items
  .filter(x => x.score > threshold)
  .map(x => heavyTransform(x))
  .sort((a, b) => b.score - a.score);

Two fixes. If the data set is large, paginate before you transform. If the transform is genuinely CPU-bound, move it to a worker thread or a background job queue. Do not sort 100,000 items inside an HTTP request handler.

4. Cryptographic operations without async wrappers

crypto.pbkdf2Sync, crypto.scryptSync, and bcrypt.hashSync are intentionally slow. They are designed to cost CPU. Calling them in a request handler (for example, hashing a password during a signup request) blocks the loop for the exact duration the algorithm is tuned for.

Use the async versions. Node.js offloads them to the libuv thread pool, which frees the event loop:

// Before: blocks the loop for 100+ ms
const hash = bcrypt.hashSync(password, 12);

// After: runs in the thread pool, loop stays free
const hash = await bcrypt.hash(password, 12);

The same rule applies to crypto.pbkdf2, crypto.scrypt, and crypto.randomFill. Always use the callback or promise versions in request handlers.

5. Catastrophic regular expression backtracking

A regex like /^(a+)+$/ on a 30-character string of as followed by a b can take seconds to fail. Real-world regex backtracking is more subtle: validation rules that use nested quantifiers on user-provided strings. An attacker who knows your regex can send a 100-byte payload that keeps the CPU busy for minutes.

The fix is input length limits before regex matching, and regexes that are linear time. Use a library like RE2 from Google, which guarantees linear execution time by dropping some advanced features like backreferences. For simple patterns, keep them simple. For complex validation, parse with a real parser instead of a regex.

Finding the exact blocking function

Once the monitor shows a spike, you need the function name. The monitor tells you when, not what. There are two practical ways to get the what.

Option 1: Clinic Doctor

clinic doctor (from NearForm) runs your process, samples the event loop, and produces a report that flags whether the bottleneck is I/O, CPU, or memory. Run it on a staging instance under load:

npx clinic doctor -- node server.js
# then apply load with autocannon or k6
# Ctrl+C and clinic generates an HTML report

If the event loop graph shows a flat line during a latency spike, the loop is blocked. The CPU graph will show 100% on one core. The flame graph from clinic flame will tell you exactly which function consumed the time.

Option 2: Async hooks with a long-stack profiler

For production-safe lightweight tracing, you can use async_hooks to tag the start of each request and compare the actual resume time to the expected time. This is heavier than the lag monitor, so only enable it for short debugging sessions:

import { createHook, executionAsyncId } from 'node:async_hooks';

const startTimes = new Map();

const hook = createHook({
  init(asyncId, type, triggerAsyncId) {
    if (type === 'TCPWRAP' || type === 'HTTPParser') {
      startTimes.set(asyncId, performance.now());
    }
  },
  before(asyncId) {
    const start = startTimes.get(asyncId);
    if (start) {
      const lag = performance.now() - start;
      if (lag > 200) {
        console.log(JSON.stringify({
          event: 'async_resume_lag',
          asyncId,
          lagMs: Math.round(lag),
          timestamp: new Date().toISOString()
        }));
      }
    }
  }
});

hook.enable();

This tells you which async resources resumed late, which narrows down the blocking window to a specific request or connection.

Alerting rules that matter

Event loop lag is not a metric you graph and ignore. You alert on it.

  • Warning at 50 ms sustained. If p95 lag is above 50 ms for five minutes, something is regularly blocking the loop. It might be a moderate-size JSON parse or a small sort. Investigate before it gets worse.
  • Critical at 200 ms. If any single interval shows 200 ms of lag, requests are timing out. This is the threshold where health checks fail and load balancers start removing pods.
  • Lag + CPU correlation. If lag is high and CPU is low, you have sync I/O blocking (file system, DNS). If lag is high and CPU is high, you have a CPU-bound task. The fix is different in each case.
  • Lag during deploys. A spike right after startup usually means synchronous initialization code (reading configs, compiling regexes, building lookup maps). Move that to boot time and cache the result.

Wiring it into your metrics pipeline

If you run Prometheus, expose the histogram as a gauge:

// Inside the /metrics endpoint
const { p50, p95, p99 } = monitor.stats();
res.write(`event_loop_lag_p50 ${p50}\n`);
res.write(`event_loop_lag_p95 ${p95}\n`);
res.write(`event_loop_lag_p99 ${p99}\n`);

If you run Datadog, emit a custom metric from the structured log line using a log-to-metric rule. The JSON log format above makes this trivial: create a metric from lagMs where event equals event_loop_lag_spike.

The takeaway

Random latency spikes with flat CPU and flat memory are almost always event loop blocking. APM will mislead you because it measures async operation duration, not queue wait time. The only way to see the truth is to measure the event loop directly.

The monitor is 40 lines. Add it to every service. Alert on 50 ms sustained and 200 ms spikes. When a spike fires, look for the five common culprits: sync JSON parsing, sync file I/O, heavy array transforms, sync crypto, and regex backtracking. Fix the root cause instead of adding more pods. The event loop is the heart of your Node.js server. If it skips a beat, every request feels it.


A note from Yojji

Production-hardened Node.js services require more than feature completeness. They need runtime observability that catches the problems APM misses, like event loop lag, hidden blocking, and tail latency. That is the kind of backend engineering Yojji’s senior teams build into the systems they ship.

Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their team of 50+ engineers specializes in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and scalable microservices architecture. If your next project needs backend performance that holds up under real traffic, they are worth talking to.