DEV Community

Cover image for Why Your Node.js API Is Slow in Production: 10 Mistakes That Pass Code Review
Loknath Patel
Loknath Patel

Posted on

Why Your Node.js API Is Slow in Production: 10 Mistakes That Pass Code Review

Spot the bug in 10 seconds:

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1000);

const res = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
const body = await res.text();
Enter fullscreen mode Exit fullscreen mode

The timeout is 1 second. The server sends its headers right away, then stalls the body for 3 seconds. I ran this on Node.js 22 and it waited 3.06 seconds. No error. No timeout. Nothing in the logs.

Found it? The answer is in Mistake 8. First, a picture of why bugs like this hurt so much.

Imagine a chai tapri with one counter and one person serving. A customer reaches the counter and spends five minutes counting coins. Everyone behind him waits too, even the person who only wants a 10-rupee chai. Nobody crashes. Nobody shouts. The line just gets longer.

That is your Node.js event loop. Your JavaScript runs on one thread, and one slow operation delays every request in the queue. Most mistakes below are variations of that picture: some block the counter, some wait forever with no timeout, some send the same order to the kitchen 100 times.

I reproduced ten mistakes like these on Node.js 22. They look fine in code review, they throw no errors, and they only hurt under real traffic. A few results from my tests:

  • A /fast route that took 190 ms instead of 0.7 ms because a different request was running (Mistake 1).
  • A cache that read the same file 100 times for 100 concurrent requests instead of once (Mistake 7).
  • An event-loop monitor that printed 20365311 ms of lag on a completely idle server (explained in the next section).

Every snippet marked Tested has a script you can run in the companion repo. Database and framework snippets are illustrative: names like pool, app and getUser stand in for your own code, and they were not run against a real database.

The golden rule: measure first

Optimizing without measuring is guessing. Start with a timer around suspicious code:

const { performance } = require('node:perf_hooks');

async function timedRoute(label, fn) {
  const start = performance.now();
  const result = await fn();
  console.log(`[PERF] ${label}: ${(performance.now() - start).toFixed(1)}ms`);
  return result;
}
Enter fullscreen mode Exit fullscreen mode

Node ships more in perf_hooks: performance.now(), PerformanceObserver, performance.eventLoopUtilization() and monitorEventLoopDelay().

Timers only tell you how long something took. For the bigger picture, use a profiler and a load test:

  • node --cpu-prof server.js writes a .cpuprofile file when the process exits. Open it in Chrome DevTools.
  • Clinic.js draws flame graphs from a running app.
  • npx autocannon -c 100 -d 30 http://localhost:3000/your-route shows latency under 100 concurrent connections. Many bugs below only show up under concurrency.

The 20365311 gotcha

The docs say monitorEventLoopDelay() reports delays in nanoseconds. On an idle Node 22 process with resolution: 20, my p99 was 20,316,159. That is nanoseconds, about 20.3 ms. Print it with an ms label and you get twenty million milliseconds of lag, over five hours, on a server doing nothing. That was the number in the intro.

The 20 ms is not real lag. The values appear to include the sampling interval, so convert to milliseconds and subtract the resolution:

const { monitorEventLoopDelay } = require('node:perf_hooks');

const RESOLUTION_MS = 20;
const h = monitorEventLoopDelay({ resolution: RESOLUTION_MS });
h.enable();

setInterval(() => {
  const toMs = (ns) => ns / 1e6;
  const p99 = Math.max(0, toMs(h.percentile(99)) - RESOLUTION_MS);
  const max = Math.max(0, toMs(h.max) - RESOLUTION_MS);
  console.log(`event loop lag: p99 ~ ${p99.toFixed(1)}ms, max ~ ${max.toFixed(1)}ms`);
  h.reset();
}, 10_000).unref();
Enter fullscreen mode Exit fullscreen mode

Tested: with this 10-second window and one deliberate 300 ms synchronous block, p99 stayed around 0.6 ms while max reported about 297 ms. The window held about 476 samples, so a single stall is one sample and p99 can miss it. Watch max (or use a shorter window) as well, and pick alert thresholds from your own baseline.

Each mistake below has an Impact tag. These are my judgment, not a measurement: Outage-level can take the whole service down, High means large latency or load that grows with your data or traffic, and Medium is wasteful and noticeable at scale.

1. One slow function freezes every request

Impact: Outage-level.

Node runs your JavaScript on one thread. A synchronous operation inside a request handler delays every other request, not just the current one. Typical culprits are fs.readFileSync(), crypto.pbkdf2Sync(), huge JSON.parse() / JSON.stringify() calls, and heavy loops or regexes.

Tested: I ran a server in one process and a load generator in another. A pbkdf2Sync call took about 235 ms by itself. While it ran, requests to an unrelated /fast route had a median latency of 190 ms and a worst case of 233 ms. With the async pbkdf2, the same /fast route stayed at a median of 0.7 ms. Same work, same machine. The only difference is whether the counter was blocked.

Fixes

  1. Do synchronous work once at startup. readFileSync at boot is fine.
  2. At runtime, use the async APIs. crypto.pbkdf2, fs.promises, zlib and similar calls run on libuv's thread pool (4 threads by default, so it is small, not unlimited).
  3. For CPU-bound work you cannot make async, move it to worker_threads. Do not spawn a worker per request; use a pool such as Piscina.
const { Worker } = require('node:worker_threads');

function runInWorker(file, data) {
  return new Promise((resolve, reject) => {
    const w = new Worker(file, { workerData: data });
    w.once('message', resolve);
    w.once('error', reject);
    w.once('exit', (code) => {
      if (code !== 0) reject(new Error(`worker exited with code ${code}`));
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

A caveat: reading a small file synchronously from a local disk is cheap. Blocking becomes a real problem with large files, slow or network disks, and heavy CPU work, so measure before you rewrite.

Blocking is the famous one. The next one is the most common bug in database-backed APIs, and it hides behind code that reads well.

2. The N+1 query: one endpoint, one query per row

Impact: High.

You load a list, then run one more query per item. The code looks clean and the database suffers.

// N+1: 1 query for orders + 1 query per order
async function getUserOrders(userId) {
  const { rows: orders } = await pool.query(
    'SELECT * FROM orders WHERE user_id = $1', [userId]
  );
  for (const order of orders) {
    const { rows } = await pool.query(
      'SELECT * FROM order_items WHERE order_id = $1', [order.id]
    );
    order.items = rows;
  }
  return orders;
}
Enter fullscreen mode Exit fullscreen mode

Batch the child lookup so the query count stays at two, however many orders there are:

async function getUserOrders(userId) {
  const { rows: orders } = await pool.query(
    'SELECT id, status, total FROM orders WHERE user_id = $1', [userId]
  );
  if (orders.length === 0) return [];

  const { rows: items } = await pool.query(
    'SELECT * FROM order_items WHERE order_id = ANY($1)',
    [orders.map((o) => o.id)]
  );

  const byOrder = new Map();
  for (const item of items) {
    if (!byOrder.has(item.order_id)) byOrder.set(item.order_id, []);
    byOrder.get(item.order_id).push(item);
  }
  return orders.map((o) => ({ ...o, items: byOrder.get(o.id) ?? [] }));
}
Enter fullscreen mode Exit fullscreen mode

A single JOIN also works, but it repeats parent columns on every child row, so you have to regroup the rows. In GraphQL servers, DataLoader batches per-tick lookups for you.

With an ORM, N+1 hides behind property access: order.items looks like a field but fires a query. Use eager loading (include in Prisma and Sequelize) and read the SQL log once to confirm.

How to find it: log how many queries each request runs. A request that suddenly runs hundreds is the signal. Some APM tools flag repeated query patterns automatically, and this Scout write-up shows one way to detect it with Prisma.

3. Sending columns nobody reads

Impact: Medium.

SELECT *, or returning a whole ORM document, sends columns the client never uses. That costs database I/O, network bytes and serialization time, and it can leak fields you never meant to expose.

app.get('/api/users/:id', async (req, res) => {
  const { rows } = await pool.query(
    'SELECT id, username, profile_image_url, created_at FROM users WHERE id = $1',
    [req.params.id]
  );
  res.json(rows[0] ?? null);
});
Enter fullscreen mode Exit fullscreen mode

I did not measure the database side, but I can measure what the extra columns do to the response. Tested with synthetic rows: 10,000 rows with 4 fields serialize to 1.27 MB in about 3 ms. The same rows with 30 fields are 11.7 MB and take about 55 ms. That is roughly 9 times the bytes and 19 times the event-loop time, and the client only needed 4 fields.

That serialization time is the bridge to Mistake 10. First, a mistake that costs you latency without blocking anything.

4. Serial await that should run in parallel

Impact: Medium.

Independent calls should run together. Sequential:

// Total time is the sum of all three
const user = await getUser(id);
const orders = await getOrders(id);
const prefs = await getPreferences(id);
Enter fullscreen mode Exit fullscreen mode

Parallel:

// Total time is about the slowest one
const [user, orders, prefs] = await Promise.all([
  getUser(id), getOrders(id), getPreferences(id),
]);
Enter fullscreen mode Exit fullscreen mode

Tested with three simulated 200 ms calls: sequential took about 602 ms, Promise.all about 201 ms.

Things to watch:

  • Only parallelize independent work. A checkout flow like validate cart, calculate totals, create order, charge payment is dependent: each step needs the previous result, so it must stay sequential.
  • await does not block the event loop. The cost of serial awaits is latency for that one request.
  • Promise.all rejects as soon as one promise rejects, and the others keep running (they are not cancelled). Use Promise.allSettled when you need every result.
  • Parallel queries each take a pool connection, so a high fan-out can exhaust a small pool (Mistake 6).

Three of these ten can take a whole service down, not just slow it. You have already seen one: Mistake 1. The other two are Mistake 6 and Mistake 8. But the next one is the one that gets quietly worse every month, until the day it does not.

5. The query that was fine at 100 rows

Impact: High.

A query that filters, joins or sorts on an unindexed column scans the whole table. That is fine at 100 rows and painful at millions. Confirm with EXPLAIN ANALYZE:

EXPLAIN ANALYZE SELECT id, status, total FROM orders WHERE customer_id = 42;
-- Seq Scan on orders  -> consider an index

CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders (customer_id);
-- run EXPLAIN ANALYZE again: look for an Index Scan
Enter fullscreen mode Exit fullscreen mode

Indexes are not free. They use disk space and slow down writes, so add the ones your real queries need and verify they are used.

CONCURRENTLY avoids blocking writes while building an index on a live table, but it cannot run inside a transaction block (see the PostgreSQL docs). Many migration tools wrap each migration in a transaction, so mark that migration as non-transactional.

Also remember that EXPLAIN ANALYZE actually runs the query. For INSERT, UPDATE or DELETE, use plain EXPLAIN or run it inside a transaction you roll back.

6. A new database connection per request

Impact: Outage-level.

PostgreSQL (pg): create one pool at startup and reuse it. Never create a Client or Pool per request. And set the timeouts, because the defaults wait forever.

const { Pool } = require('pg');

const pool = new Pool({
  max: 10,                        // per process. See the math below
  connectionTimeoutMillis: 5_000, // fail fast if no connection is free (default 0 = wait forever)
  statement_timeout: 10_000,      // the server cancels any query running longer than 10 s
});

// An idle client can error (for example when the database restarts).
// Without a listener, that unhandled 'error' event can crash the process.
pool.on('error', (err) => console.error('idle pg client error:', err.message));

app.get('/data', async (req, res) => {
  const { rows } = await pool.query('SELECT id, name FROM users LIMIT 50');
  res.json(rows);
});
Enter fullscreen mode Exit fullscreen mode

For transactions or multi-statement work, check out a client and always release it. If ROLLBACK itself fails, the connection is in an unknown state. The node-postgres docs say that passing true to release() makes the pool destroy that client instead of reusing it:

async function transfer(a, b, amount) {
  const client = await pool.connect();
  let destroy = false;
  try {
    await client.query('BEGIN');
    // ... statements ...
    await client.query('COMMIT');
  } catch (err) {
    try {
      await client.query('ROLLBACK');
    } catch (rollbackErr) {
      destroy = true; // do not return a broken client to the pool
      console.error('ROLLBACK failed:', rollbackErr.message);
    }
    throw err; // always surface the original error
  } finally {
    client.release(destroy);
  }
}
Enter fullscreen mode Exit fullscreen mode

The production math nobody does in dev

The pool limit is per process. Postgres has one global limit. If you run 10 instances with max: 20, that is 200 connections, and Postgres' default max_connections is typically 100 (it can be lower on some systems). Autoscaling multiplies the problem exactly when traffic is highest. Pick max so that instances x max stays under the database limit with headroom, or put a pooler like PgBouncer in front.

Connections are not free on the server either: each one is a separate Postgres process, and memory per connection depends on your workload (see this PGConf talk). Keep the count bounded and pooled. See the node-postgres pool docs for every option.

MongoDB: every MongoClient already contains a connection pool (default maxPoolSize is 100). The mistake is creating a new client per request. The driver docs say to create one client and reuse it for all operations in a process.

Two of the three outage-level mistakes are behind us. The third is the puzzle from the top. But first, a mistake that makes everything above cheaper.

7. Recomputing the same thing on every request

Impact: Medium.

If every request recomputes or refetches the same data, you pay for it on every request. Cache in layers:

  • In-process for small, hot, read-mostly data such as config and reference tables.
  • A shared cache (for example Redis) when several instances need the same data.
  • HTTP caching (Cache-Control, ETag) for browsers and CDNs.

But the obvious in-process cache has a trap. Here is a feature-flag loader that looks correct:

let cache = null;

async function loadFlags() {
  if (cache) return cache;
  cache = JSON.parse(await fs.readFile('/etc/app/feature-flags.json', 'utf8'));
  return cache;
}
Enter fullscreen mode Exit fullscreen mode

The value is stored only after the await. Every request that arrives while the first read is still running starts its own read. This is a cache stampede. Tested: 100 concurrent callers on a cold cache caused 100 file reads. Caching the promise instead caused 1.

Here is a small in-process cache that caches the promise, de-duplicates in-flight calls, evicts the least recently used entry, bounds its own size, and stops sharing a call that never finishes (pendingMs, which you should set above your function's own timeout from Mistake 8):

function cached(fn, ttlMs, { maxEntries = 1000, pendingMs = 30_000 } = {}) {
  const store = new Map(); // insertion order = least recently used first

  return (key) => {
    const now = Date.now();
    const hit = store.get(key);
    if (hit && hit.expires > now) {            // pending entries expire after pendingMs
      store.delete(key); store.set(key, hit);  // touch: move to most recently used
      return hit.promise;
    }

    const entry = { promise: null, expires: now + pendingMs };
    // new Promise(...) also turns a synchronous throw from fn into a rejection
    entry.promise = new Promise((resolve) => resolve(fn(key))).then((value) => {
      entry.expires = Date.now() + ttlMs;      // the TTL starts when the call finishes
      return value;
    });

    store.delete(key);
    store.set(key, entry);
    if (store.size > maxEntries) store.delete(store.keys().next().value); // evict LRU

    // never cache failures, and never delete a newer entry for the same key
    entry.promise.catch(() => { if (store.get(key) === entry) store.delete(key); });
    return entry.promise;
  };
}

const getUserCached = cached(loadUserFromDb, 5_000);
Enter fullscreen mode Exit fullscreen mode

Tested:

  • 100 concurrent calls for the same key triggered 1 underlying call.
  • After 10,000 distinct keys with maxEntries: 100, only the last 100 were still cached.
  • A hot key that is read again survives eviction; a cold key does not (LRU, not FIFO).
  • With a 600 ms call and a 400 ms TTL, a second call right after completion was a cache hit. If the TTL started when the call started, any call slower than its TTL would be expired the moment it finished.
  • A failed call is not cached, and a slow old failure does not delete a newer entry for the same key.
  • A call that never finishes is shared only for pendingMs. After that, the next caller starts a fresh call instead of waiting on a hung promise forever.

Caching has costs. Decide how stale data may be, how entries are invalidated, and how big the cache may grow. With several instances, in-process caches can disagree with each other.

Caching protects you from slow dependencies. The next mistake is about dependencies that never answer at all.

8. Outbound calls with no timeout, or a leaky one

Impact: Outage-level.

Here is the answer to the puzzle at the top. If you call clearTimeout as soon as fetch resolves, the timeout no longer covers reading the body:

// Timer is cleared when headers arrive, so a stalled body is NOT covered
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 1000);
const res = await fetch(url, { signal: controller.signal });
clearTimeout(timer);
await res.text();
Enter fullscreen mode Exit fullscreen mode

fetch() resolves when the headers arrive. The body is a separate stream, and it is still being read after your timer is gone.

Tested against a server that sends headers, then stalls the body for 3 seconds, with a 1-second timeout:

Pattern Result
Manual timer, cleared right after fetch finished after 3.06 s, no timeout applied
AbortSignal.timeout(1000) aborted after 1.00 s with a TimeoutError
Manual timer, cleared in finally after the body aborted after 1.00 s with an AbortError

The simplest fix is the built-in signal:

const res = await fetch('https://api.example.com/data', {
  signal: AbortSignal.timeout(5_000),
});
const data = await res.json();
Enter fullscreen mode Exit fullscreen mode

If you prefer a manual AbortController, clear the timer only after the body is fully read:

async function fetchText(url, ms = 5_000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
  try {
    const res = await fetch(url, { signal: controller.signal });
    return await res.text(); // the timer still covers reading the body
  } finally {
    clearTimeout(timer);
  }
}
Enter fullscreen mode Exit fullscreen mode

Every outbound call means the database too. Mistake 6 showed connectionTimeoutMillis and statement_timeout. A query without a timeout is the same bug as a fetch without one.

Good to know: Node's built-in fetch (undici) defaults to 300 s for both the headers timeout and the body timeout, so a stuck request will not hang forever, but five minutes is far too long for most APIs. On the server side, Node 22's http.createServer() defaults to requestTimeout 300000 ms and headersTimeout 60000 ms. Review them for your traffic.

That was the last outage-level one. The remaining mistakes are about memory and CPU, starting with a very common way to waste both.

9. Loading a whole file into memory just to send it

Impact: Medium.

This looks harmless:

app.get('/download', async (req, res) => {
  const data = await fs.promises.readFile('./large-file.zip'); // whole file in memory, per request
  res.send(data);
});
Enter fullscreen mode Exit fullscreen mode

readFile is asynchronous, so it does not block the event loop, but it loads the whole file into memory for every concurrent request. If you are just serving a file from disk, Express's res.sendFile() and res.download() already stream it. When you need your own logic, stream with pipeline() so errors are handled and streams are cleaned up:

const fs = require('node:fs');
const { pipeline } = require('node:stream/promises');

app.get('/download', async (req, res, next) => {
  try {
    await fs.promises.stat('./large-file.zip'); // 404 before streaming starts
    res.setHeader('Content-Type', 'application/zip');
    await pipeline(fs.createReadStream('./large-file.zip'), res);
  } catch (err) {
    if (res.headersSent) {
      // too late for a status code; pipeline already closed the response.
      // A client closing the tab mid-download is normal, so do not log it as an error.
      if (err.code !== 'ERR_STREAM_PREMATURE_CLOSE') console.error('stream failed:', err.code);
      return;
    }
    if (err.code === 'ENOENT') return res.status(404).end('not found');
    next(err);
  }
});
Enter fullscreen mode Exit fullscreen mode

Tested with a plain Node http server:

  • A missing file returned 404, and a 5 MB file streamed completely (5,242,880 bytes).
  • When the client disconnected mid-download, pipeline() rejected with ERR_STREAM_PREMATURE_CLOSE after the headers had already been sent. That is why the catch checks res.headersSent before trying to send a status.
  • With fs.createReadStream(missing).pipe(res), the same missing file crashed the process with an unhandled ENOENT error event, because pipe() does not forward errors. pipeline() does.

Both pipe() and pipeline() handle backpressure for you. Checking the return value of write() is only needed when you call write() manually.

Streaming helps with big files. Big JSON has the same shape of problem, but it blocks the loop instead of filling memory.

10. Serializing big JSON on the event loop

Impact: Medium.

JSON.stringify() is synchronous. A very large response blocks the event loop for the whole time it serializes, which is Mistake 1 in disguise. Tested, median of several runs on a 1 vCPU test box:

Payload Rows JSON.stringify time
0.12 MB 1,000 rows, 4 fields 0.2 ms
1.27 MB 10,000 rows, 4 fields 2.9 ms
11.7 MB 10,000 rows, 30 fields 54.5 ms
13.0 MB 100,000 rows, 4 fields 43 ms
119.9 MB 100,000 rows, 30 fields 573 ms

That is roughly 3 to 5 ms of blocked event loop per MB on this machine. Your hardware will differ, so run the script on yours. A 120 MB response blocks the counter for over half a second, and every other request waits.

The first fixes are boring, and they work:

  1. Return fewer fields (Mistake 3).
  2. Paginate. Do not return 100,000 rows from a list endpoint.
  3. For exports, stream the rows (for example as NDJSON) instead of building one giant array.
  4. For rare, genuinely huge payloads, serialize in a worker thread.

Schema-based serializers such as fast-json-stringify (used by Fastify for response schemas) are often suggested. In my test, with one simple 4-field schema, it was slower than plain JSON.stringify at every size I tried, for example about 196 ms against 29 ms at 100,000 rows. That does not mean it is slow in general. It means one schema on one machine did not show a gain, so benchmark your own payloads before adopting it.

Bonus 1: AsyncLocalStorage is not free

AsyncLocalStorage is the standard way to carry request context (request IDs, tenants, tracing) without passing it through every function. Do not trust a client-supplied ID blindly: it ends up in your logs, so validate its shape and length.

const { AsyncLocalStorage } = require('node:async_hooks');
const crypto = require('node:crypto');

const requestContext = new AsyncLocalStorage();
const SAFE_ID = /^[A-Za-z0-9._-]{1,64}$/;

app.use((req, res, next) => {
  const incoming = req.headers['x-request-id'];
  const requestId = typeof incoming === 'string' && SAFE_ID.test(incoming)
    ? incoming
    : crypto.randomUUID();
  requestContext.run({ requestId }, next);
});

function log(message) {
  const ctx = requestContext.getStore();
  console.log(`[${ctx?.requestId}] ${message}`);
}
Enter fullscreen mode Exit fullscreen mode

Tested: two concurrent requests each logged their own request ID, and a header containing spaces and a tab was replaced with a generated UUID.

On cost, benchmark conditions matter. Platformatic's benchmark (a simple HTTP server, 100 connections) measured AsyncLocalStorage at about 93% of baseline throughput on Node 24.4.1, roughly 7% overhead, and about 90% on Node 22.17.1, roughly 10%. I did not reproduce those numbers. A micro-benchmark that isolates the primitive will show a larger percentage. Measure it inside your own app before deciding.

Bonus 2: Async errors in Express 4 vs Express 5

In Express 5, a rejected promise from an async handler is passed to your error handler automatically (see the migration guide). Express 4 does not do that.

Tested on Node 22 with Express 4: an async handler that throws did not reach the error middleware. The client got a dropped connection, and the whole server process exited with code 1, because Node terminates the process on an unhandled promise rejection by default. If you installed an unhandledRejection listener, you would see the other symptom instead: a request that hangs until a timeout.

On Express 4, wrap your async handlers:

const asyncHandler = (fn) => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await getUser(req.params.id);
  if (!user) return res.status(404).json({ error: 'not_found' });
  res.json(user);
}));
Enter fullscreen mode Exit fullscreen mode

Bonus 3: Framework benchmarks are not your benchmarks

Fastify's README says its headline throughput numbers come from a synthetic hello-world benchmark that measures framework overhead only. Those numbers say little about an endpoint that spends its time waiting on a database. Benchmark your own routes before switching frameworks.

Back to the puzzle

The 1-second timeout that waited 3.06 seconds was Mistake 8: the timer was cleared as soon as the headers arrived. The 20365311 "milliseconds" of lag was nanoseconds, about 20.3 ms, mostly the sampling interval. The cache that read a file 100 times was Mistake 7. And the /fast route that took 190 ms instead of 0.7 ms was one synchronous call in someone else's handler.

If you only have an hour in your own codebase, here is the order I would check them, based on my impact tags and not on any benchmark of your system:

  1. Timeouts (8 and 6). Search for every fetch, HTTP client and database call. Does each one have a timeout that covers the whole response?
  2. Pool math (6). Multiply instances by max and compare it with the database limit.
  3. Event loop lag (1). Add the monitor from the start of this article and alert on max, not only p99.

Quick checklist

  1. No synchronous I/O or heavy CPU work in request handlers. Monitor event loop max.
  2. Batch child queries (no N+1), including the ones your ORM hides.
  3. Select only the columns you return.
  4. Parallelize only independent work.
  5. Index what you filter, join and sort on, and verify with EXPLAIN ANALYZE.
  6. One pool or client per process, with pool.on('error'), connectionTimeoutMillis, statement_timeout, and instances x max under the database limit.
  7. Cache the promise, not just the value: TTL, in-flight de-duplication, LRU and a size cap, plus a plan for staleness and invalidation.
  8. A timeout on every outbound call, including the body and including the database.
  9. Stream large responses with pipeline().
  10. Paginate big responses and benchmark serializers on your own data.

Top comments (0)