<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>ApiTraffic Blog</title>
    <link>https://www.apitraffic.io/blog</link>
    <description>API observability, APM and application security for Node.js teams.</description>
    <language>en-us</language>
    <lastBuildDate>Fri, 30 Oct 2026 10:46:32 GMT</lastBuildDate>
    <atom:link href="https://www.apitraffic.io/rss.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>W3C Trace Context in Node.js: correlating requests across services with traceparent</title>
      <link>https://www.apitraffic.io/blog/w3c-trace-context-node</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/w3c-trace-context-node</guid>
      <pubDate>Tue, 27 Oct 2026 20:48:06 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>Three services, three log streams, one failed order, and nothing to join them on. W3C Trace Context gives every hop the same trace ID. Here is how to propagate traceparent in Node with OpenTelemetry, or in 30 lines without it.</description>
      <content:encoded><![CDATA[<p>A customer’s order failed. The API gateway logged a <code>502</code>, the orders service logged a timeout, and the payments service logged a <code>400</code> at roughly the same second, along with a few hundred other requests. Without a shared ID, you are matching timestamps by eye. W3C Trace Context is the standard way to give every hop of one request the same ID.</p>
<p>The short answer: every service reads the incoming <code>traceparent</code> header, keeps its trace ID for the duration of the request, writes that ID into every log line, and sends a new <code>traceparent</code> with the same trace ID on every outbound call. With OpenTelemetry’s Node SDK and auto-instrumentations, inbound HTTP, outbound <code>http</code> and <code>fetch</code> calls get this for free. Without OpenTelemetry, it is about 30 lines of code. Both are below.</p>
<h2 id="what-is-w3c-trace-context">What is W3C Trace Context? <a class="post-heading-anchor" href="#what-is-w3c-trace-context" aria-hidden="true">#</a></h2>
<p>W3C Trace Context is a <a href="https://www.w3.org/TR/trace-context-1/" target="_blank" rel="noopener noreferrer">W3C Recommendation (Level 1, last published November 2021)</a> that defines two HTTP headers: <code>traceparent</code>, which carries the trace identity, and <code>tracestate</code>, which carries vendor-specific data. Because it is a standard, a trace can pass through an <a href="https://www.apitraffic.io/express">Express</a> service, a Fastify service, a load balancer and a third-party tool and keep the same ID.</p>
<p>A <code>traceparent</code> looks like this:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">http</span></div><pre><code class="language-http hljs"><span class="hljs-attribute">traceparent</span><span class="hljs-punctuation">: </span>00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
</code></pre></div>
<div class="post-table"><table>
<thead>
<tr>
<th>Field</th>
<th>Example</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td>version</td>
<td><code>00</code></td>
<td>Format version; <code>ff</code> is invalid</td>
</tr>
<tr>
<td>trace-id</td>
<td><code>4bf92f35...0e4736</code></td>
<td>32 lowercase hex characters, same for every hop of the request</td>
</tr>
<tr>
<td>parent-id</td>
<td><code>00f067aa0ba902b7</code></td>
<td>16 hex characters, the ID of the caller’s span</td>
</tr>
<tr>
<td>trace-flags</td>
<td><code>01</code></td>
<td>Bit 0 is “sampled”</td>
</tr>
</tbody>
</table></div>
<p>A trace ID or parent ID of all zeros is invalid. Receivers must accept the header name in any case, and <code>tracestate</code> can hold at most 32 list members.</p>
<h3 id="how-is-traceparent-different-from-x-request-id">How is traceparent different from X-Request-ID? <a class="post-heading-anchor" href="#how-is-traceparent-different-from-x-request-id" aria-hidden="true">#</a></h3>
<p>A request ID usually identifies one hop. The trace ID identifies the whole chain. You can keep both, or use the trace ID as your request ID. If you already followed our post on <a href="/blog/asynclocalstorage-request-id">request IDs and AsyncLocalStorage</a>, the structure below will look familiar: the difference is a standard header and a standard format that other tools understand.</p>
<h2 id="option-1-w3c-trace-context-with-opentelemetry-in-node-js">Option 1: W3C Trace Context with OpenTelemetry in Node.js <a class="post-heading-anchor" href="#option-1-w3c-trace-context-with-opentelemetry-in-node-js" aria-hidden="true">#</a></h2>
<p><a href="https://opentelemetry.io/docs/languages/js/propagation/" target="_blank" rel="noopener noreferrer">OpenTelemetry’s default propagators in JavaScript</a> are W3C Trace Context and W3C Baggage. The <a href="https://www.npmjs.com/package/@opentelemetry/auto-instrumentations-node" target="_blank" rel="noopener noreferrer">auto-instrumentations package</a> includes HTTP, Express, undici (which is what Node’s global <code>fetch</code> uses), Postgres and Pino instrumentation, among others.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">bash</span></div><pre><code class="language-bash hljs">npm install @opentelemetry/sdk-node @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http
</code></pre></div>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// instrumentation.js  (load before anything else)</span>
<span class="hljs-keyword">const</span> { <span class="hljs-title">NodeSDK</span> } = <span class="hljs-built_in">require</span>(<span class="hljs-string">'@opentelemetry/sdk-node'</span>);
<span class="hljs-keyword">const</span> { getNodeAutoInstrumentations } = <span class="hljs-built_in">require</span>(<span class="hljs-string">'@opentelemetry/auto-instrumentations-node'</span>);
<span class="hljs-keyword">const</span> { <span class="hljs-title">OTLPTraceExporter</span> } = <span class="hljs-built_in">require</span>(<span class="hljs-string">'@opentelemetry/exporter-trace-otlp-http'</span>);

<span class="hljs-keyword">const</span> sdk = <span class="hljs-keyword">new</span> <span class="hljs-title">NodeSDK</span>({
  <span class="hljs-attr">serviceName</span>: process.<span class="hljs-property">env</span>.<span class="hljs-property">SERVICE_NAME</span> ?? <span class="hljs-string">'orders-api'</span>,
  <span class="hljs-attr">traceExporter</span>: <span class="hljs-keyword">new</span> <span class="hljs-title">OTLPTraceExporter</span>(),   <span class="hljs-comment">// reads OTEL_EXPORTER_OTLP_ENDPOINT</span>
  <span class="hljs-attr">instrumentations</span>: [<span class="hljs-title">getNodeAutoInstrumentations</span>()],
});
sdk.<span class="hljs-title">start</span>();

process.<span class="hljs-title">on</span>(<span class="hljs-string">'SIGTERM'</span>, <span class="hljs-function">() =&gt;</span> sdk.<span class="hljs-title">shutdown</span>().<span class="hljs-title">finally</span>(<span class="hljs-function">() =&gt;</span> process.<span class="hljs-title">exit</span>(<span class="hljs-number">0</span>)));
</code></pre></div>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">bash</span></div><pre><code class="language-bash hljs">node -r ./instrumentation.js server.js
</code></pre></div>
<p>That is enough for:</p>
<ul>
<li>inbound requests: <code>traceparent</code> is extracted, and the request’s span joins the caller’s trace</li>
<li>outbound <code>http.request</code> and <code>fetch</code> calls: a new <code>traceparent</code> with the same trace ID is injected</li>
<li>Pino logs: the Pino instrumentation adds <code>trace_id</code> and <code>span_id</code> to log records written inside a traced request</li>
</ul>
<p>Load order matters. The instrumentation has to load before Express, <code>pg</code> and Pino are required, which is why it goes in <code>-r</code> rather than at the top of <code>server.js</code>. For Fastify, the Fastify team maintains <code>@fastify/otel</code> for route-level spans; inbound propagation already works through the HTTP instrumentation.</p>
<h2 id="option-2-propagate-traceparent-by-hand">Option 2: propagate traceparent by hand <a class="post-heading-anchor" href="#option-2-propagate-traceparent-by-hand" aria-hidden="true">#</a></h2>
<p>If you do not want an OpenTelemetry pipeline yet, propagation alone is small. This version accepts version <code>00</code> only, which is what every current sender emits:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// trace-context.js</span>
<span class="hljs-keyword">import</span> { <span class="hljs-title">AsyncLocalStorage</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:async_hooks'</span>;
<span class="hljs-keyword">import</span> { randomBytes } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:crypto'</span>;

<span class="hljs-keyword">const</span> als = <span class="hljs-keyword">new</span> <span class="hljs-title">AsyncLocalStorage</span>();
<span class="hljs-keyword">const</span> <span class="hljs-variable">TRACEPARENT</span> = <span class="hljs-regexp">/^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/</span>;
<span class="hljs-keyword">const</span> <span class="hljs-title">hex</span> = (<span class="hljs-params">bytes</span>) =&gt; <span class="hljs-title">randomBytes</span>(bytes).<span class="hljs-title">toString</span>(<span class="hljs-string">'hex'</span>);

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">parseTraceparent</span>(<span class="hljs-params">value</span>) {
  <span class="hljs-keyword">const</span> m = <span class="hljs-variable">TRACEPARENT</span>.<span class="hljs-title">exec</span>((value ?? <span class="hljs-string">''</span>).<span class="hljs-title">trim</span>().<span class="hljs-title">toLowerCase</span>());
  <span class="hljs-keyword">if</span> (!m || <span class="hljs-regexp">/^0+$/</span>.<span class="hljs-title">test</span>(m[<span class="hljs-number">1</span>]) || <span class="hljs-regexp">/^0+$/</span>.<span class="hljs-title">test</span>(m[<span class="hljs-number">2</span>])) <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
  <span class="hljs-keyword">return</span> { <span class="hljs-attr">traceId</span>: m[<span class="hljs-number">1</span>], <span class="hljs-attr">parentId</span>: m[<span class="hljs-number">2</span>], <span class="hljs-attr">flags</span>: m[<span class="hljs-number">3</span>] };
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">traceContext</span>(<span class="hljs-params">{ trustIncoming = <span class="hljs-literal">true</span> } = {}</span>) {
  <span class="hljs-keyword">return</span> <span class="hljs-function">(<span class="hljs-params">req, res, next</span>) =&gt;</span> {
    <span class="hljs-keyword">const</span> incoming = trustIncoming ? <span class="hljs-title">parseTraceparent</span>(req.<span class="hljs-title">get</span>(<span class="hljs-string">'traceparent'</span>)) : <span class="hljs-literal">null</span>;
    <span class="hljs-keyword">const</span> ctx = {
      <span class="hljs-attr">traceId</span>: incoming?.<span class="hljs-property">traceId</span> ?? <span class="hljs-title">hex</span>(<span class="hljs-number">16</span>),
      <span class="hljs-attr">spanId</span>: <span class="hljs-title">hex</span>(<span class="hljs-number">8</span>),
      <span class="hljs-attr">flags</span>: incoming?.<span class="hljs-property">flags</span> ?? <span class="hljs-string">'01'</span>,
    };
    res.<span class="hljs-title">set</span>(<span class="hljs-string">'x-trace-id'</span>, ctx.<span class="hljs-property">traceId</span>);   <span class="hljs-comment">// handy for support tickets</span>
    als.<span class="hljs-title">run</span>(ctx, next);
  };
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title">currentTrace</span> = (<span class="hljs-params"></span>) =&gt; als.<span class="hljs-title">getStore</span>();

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">tracedFetch</span>(<span class="hljs-params">url, init = {}</span>) {
  <span class="hljs-keyword">const</span> ctx = <span class="hljs-title">currentTrace</span>();
  <span class="hljs-keyword">const</span> headers = <span class="hljs-keyword">new</span> <span class="hljs-title">Headers</span>(init.<span class="hljs-property">headers</span>);
  <span class="hljs-keyword">if</span> (ctx) headers.<span class="hljs-title">set</span>(<span class="hljs-string">'traceparent'</span>, <span class="hljs-string">`00-<span class="hljs-subst">${ctx.traceId}</span>-<span class="hljs-subst">${hex(<span class="hljs-number">8</span>)}</span>-<span class="hljs-subst">${ctx.flags}</span>`</span>);
  <span class="hljs-keyword">return</span> <span class="hljs-title">fetch</span>(url, { ...init, headers });
}
</code></pre></div>
<p>Wire it into Express and into your logger:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> pino <span class="hljs-keyword">from</span> <span class="hljs-string">'pino'</span>;
<span class="hljs-keyword">import</span> { traceContext, currentTrace, tracedFetch } <span class="hljs-keyword">from</span> <span class="hljs-string">'./trace-context.js'</span>;

<span class="hljs-keyword">const</span> log = <span class="hljs-title">pino</span>({ <span class="hljs-attr">mixin</span>: <span class="hljs-function">() =&gt;</span> ({ <span class="hljs-attr">trace_id</span>: <span class="hljs-title">currentTrace</span>()?.<span class="hljs-property">traceId</span> }) });
<span class="hljs-keyword">const</span> app = <span class="hljs-title">express</span>();
app.<span class="hljs-title">use</span>(<span class="hljs-title">traceContext</span>());

app.<span class="hljs-title">post</span>(<span class="hljs-string">'/v1/orders'</span>, express.<span class="hljs-title">json</span>(), <span class="hljs-title">async</span> (req, res) =&gt; {
  log.<span class="hljs-title">info</span>({ <span class="hljs-attr">items</span>: req.<span class="hljs-property">body</span>.<span class="hljs-property">items</span>?.<span class="hljs-property">length</span> }, <span class="hljs-string">'creating order'</span>);
  <span class="hljs-keyword">const</span> pay = <span class="hljs-keyword">await</span> <span class="hljs-title">tracedFetch</span>(<span class="hljs-string">'http://payments.internal/v1/charges'</span>, {
    <span class="hljs-attr">method</span>: <span class="hljs-string">'POST'</span>,
    <span class="hljs-attr">headers</span>: { <span class="hljs-string">'content-type'</span>: <span class="hljs-string">'application/json'</span> },
    <span class="hljs-attr">body</span>: <span class="hljs-title">JSON</span>.<span class="hljs-title">stringify</span>({ <span class="hljs-attr">amount</span>: <span class="hljs-number">4200</span> }),
    <span class="hljs-attr">signal</span>: <span class="hljs-title">AbortSignal</span>.<span class="hljs-title">timeout</span>(<span class="hljs-number">5000</span>),
  });
  res.<span class="hljs-title">status</span>(pay.<span class="hljs-property">ok</span> ? <span class="hljs-number">201</span> : <span class="hljs-number">502</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">ok</span>: pay.<span class="hljs-property">ok</span> });
});
</code></pre></div>
<p>Every log line from that request now carries <code>trace_id</code>, and the payments service sees the same ID in its incoming <code>traceparent</code>. Searching both services’ logs for one ID gives you the whole story. For more on what to record about those outbound calls, see <a href="/blog/log-outbound-http-requests">logging outbound HTTP calls</a>.</p>
<h2 id="where-trace-context-gets-lost">Where trace context gets lost <a class="post-heading-anchor" href="#where-trace-context-gets-lost" aria-hidden="true">#</a></h2>
<h3 id="queues-and-background-jobs">Queues and background jobs <a class="post-heading-anchor" href="#queues-and-background-jobs" aria-hidden="true">#</a></h3>
<p>HTTP headers do not cross a queue. Put the <code>traceparent</code> string into the job payload or message metadata when you enqueue, and parse it in the worker before you do anything else.</p>
<h3 id="trust-boundaries">Trust boundaries <a class="post-heading-anchor" href="#trust-boundaries" aria-hidden="true">#</a></h3>
<p>The W3C spec warns that blindly continuing any trace with the sampled flag set lets outsiders inflate your tracing costs or pollute your data with colliding IDs. On a public API, either start a fresh trace (<code>traceContext({ trustIncoming: false })</code>) and log the caller’s ID as a separate field, or only trust <code>traceparent</code> from your own front end and internal services.</p>
<h3 id="baggage-and-tracestate">Baggage and tracestate <a class="post-heading-anchor" href="#baggage-and-tracestate" aria-hidden="true">#</a></h3>
<p>Do not put user emails, tokens or anything personal in <code>baggage</code> or <code>tracestate</code>. They are forwarded to every downstream service, including third parties.</p>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<ol>
<li>Add <code>trace_id</code> to every log line in every service.</li>
<li>Accept <code>traceparent</code> on internal calls and send it on every outbound call.</li>
<li>Start a new trace at the public edge, or validate before you trust.</li>
<li>Carry <code>traceparent</code> through your queue payloads.</li>
</ol>
<p>A one-person team with two services can use Option 2 this afternoon. A ten-person team with five or more services should adopt OpenTelemetry, so new services and libraries get propagation without anyone remembering to add it.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="does-nodes-fetch-send-traceparent-automatically">Does Node’s fetch send traceparent automatically? <a class="post-heading-anchor" href="#does-nodes-fetch-send-traceparent-automatically" aria-hidden="true">#</a></h3>
<p>Not by itself. Node’s <code>fetch</code> sends <code>traceparent</code> only when something instruments it, such as OpenTelemetry’s undici instrumentation, or when you set the header yourself.</p>
<h3 id="what-is-the-difference-between-traceparent-and-tracestate">What is the difference between traceparent and tracestate? <a class="post-heading-anchor" href="#what-is-the-difference-between-traceparent-and-tracestate" aria-hidden="true">#</a></h3>
<p><code>traceparent</code> carries the IDs and the sampled flag in a fixed format. <code>tracestate</code> is an optional list of vendor-specific key-value pairs that travel alongside it.</p>
<h3 id="can-i-use-the-trace-id-as-a-request-id">Can I use the trace ID as a request ID? <a class="post-heading-anchor" href="#can-i-use-the-trace-id-as-a-request-id" aria-hidden="true">#</a></h3>
<p>Yes, as long as you also create a new span ID per hop. Returning it in a response header makes support tickets much easier to trace.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not stitch logs across services yourself, ApiTraffic records the trace ID on each captured request: from the incoming W3C <code>traceparent</code> header when there is one, otherwise from its first span. Requests from your services that share a trace ID appear together in the request’s <a href="https://www.apitraffic.io/docs/traffic/request-details">Distributed Trace tab</a>, in time order, so you can see which hop failed or was slow. <a href="https://www.apitraffic.io/docs/outbound-requests">Outbound calls are logged on the request that made them</a>.</p>
<p>The trace ID is read before redaction, so a <a href="https://www.apitraffic.io/docs/data/redactions">redaction rule</a> on <code>traceparent</code> does not break grouping.</p>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260831-w3c-trace-context-node">apitraffic.io</a></p>
<p>Sources: <a href="https://www.w3.org/TR/trace-context-1/" target="_blank" rel="noopener noreferrer">W3C, “Trace Context” Recommendation (Nov 23, 2021)</a>; <a href="https://opentelemetry.io/docs/languages/js/propagation/" target="_blank" rel="noopener noreferrer">OpenTelemetry, “Propagation” (JavaScript docs)</a>; <a href="https://www.npmjs.com/package/@opentelemetry/auto-instrumentations-node" target="_blank" rel="noopener noreferrer">npm, @opentelemetry/auto-instrumentations-node</a>; <a href="https://nodejs.org/api/async_context.html" target="_blank" rel="noopener noreferrer">Node.js docs, “Asynchronous context tracking”</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>What small Node teams should log in the AI-attack era</title>
      <link>https://www.apitraffic.io/blog/what-small-node-teams-should-log-in-the-ai-attack-era</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/what-small-node-teams-should-log-in-the-ai-attack-era</guid>
      <pubDate>Tue, 27 Oct 2026 10:04:20 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>Bots are now more than half of web traffic, and 27% of attacks target APIs and identity systems. You don&apos;t need a security team to see it, just the right fields on every request. Here&apos;s the minimum to capture, five patterns to watch, and when to block.</description>
      <content:encoded><![CDATA[<p>Log method, path, status, latency, IP, user ID, user agent and request size on every request, and keep those fields in one searchable place. Use them to watch for five patterns (login bursts, sequential ID walking, pagination scraping, 404 sweeps and new networks) for a week before you block anything. Then rate-limit login and other sensitive routes, block only what is never legitimate, and redact before anything leaves your server.</p>
<p><a href="https://www.imperva.com/resources/resource-library/reports/2026-bad-bot-report/" target="_blank" rel="noopener noreferrer">Imperva’s 2026 Bad Bot Report</a> puts bots at <a href="https://community.imperva.com/blogs/tim-chang/2026/05/05/the-2026-bad-bot-report-is-here" target="_blank" rel="noopener noreferrer">more than 53% of all web traffic, with 40% classified as malicious</a>. It also reports that <a href="https://community.imperva.com/blogs/tim-chang/2026/05/05/the-2026-bad-bot-report-is-here" target="_blank" rel="noopener noreferrer">27% of attacks now target APIs and identity systems</a>, and that <a href="https://www.thalesgroup.com/en/news-centre/press-releases/ai-driven-bot-attacks-surged-125x-according-thales-bad-bot-report" target="_blank" rel="noopener noreferrer">AI-driven bot attacks grew 12.5x</a>. AI tooling makes recon, scraping and credential stuffing cheap to write and cheap to run. Scanners don’t check your headcount before they send requests. A small team just has less time to look at what arrives.</p>
<p>You don’t need a security team to see this traffic. You need the right fields on every request, and a few patterns you know how to spot.</p>
<h2 id="1-what-your-host-logs-miss">1. What your host logs miss <a class="post-heading-anchor" href="#1-what-your-host-logs-miss" aria-hidden="true">#</a></h2>
<p>The default logs on Render, Railway, Fly or a bare VPS usually give you one line per request: timestamp, method, path, status, maybe latency. That’s enough to see that something got slow. It’s not enough to answer the questions an attack raises:</p>
<ul>
<li><strong>Who was calling?</strong> Not just the IP, but which user or API key, if any.</li>
<li><strong>What did they send?</strong> The request body is where injection attempts and bad payloads live.</li>
<li><strong>What else did they do?</strong> One request to <code>/v1/users/1042</code> is normal. Four hundred requests to <code>/v1/users/1</code> through <code>/v1/users/400</code>, from one caller, in two minutes, is not. You only see that if you can group requests by caller and look at them in order.</li>
</ul>
<p>Host logs answer the first question partially and the other two not at all.</p>
<h2 id="2-the-minimum-to-capture-for-every-request">2. The minimum to capture for every request <a class="post-heading-anchor" href="#2-the-minimum-to-capture-for-every-request" aria-hidden="true">#</a></h2>
<p>If you log nothing else, log these:</p>
<div class="post-table"><table>
<thead>
<tr>
<th>Field</th>
<th>Why it matters</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>method</code> and <code>path</code></td>
<td>What was asked for. Keep the raw path <em>and</em> the route template (<code>/v1/users/:id</code>) so you can group.</td>
</tr>
<tr>
<td><code>status</code></td>
<td>401, 403, 404 and 429 clusters are the earliest signs of probing.</td>
</tr>
<tr>
<td><code>latency_ms</code></td>
<td>Scrapers and expensive queries show up as load before they show up as errors.</td>
</tr>
<tr>
<td><code>ip</code></td>
<td>The caller’s address, correctly resolved behind your proxy.</td>
</tr>
<tr>
<td><code>user_id</code> or API key ID</td>
<td>Turns “an IP” into “an account”, which is what you’ll actually act on. Log an ID, never the token.</td>
</tr>
<tr>
<td><code>user_agent</code></td>
<td>Easy to fake, but useful for grouping. A lot of automation doesn’t bother to fake it.</td>
</tr>
<tr>
<td><code>request_size</code></td>
<td>Oversized bodies and bulk-export requests stand out.</td>
</tr>
</tbody>
</table></div>
<p>The ASN (the network an IP belongs to) is worth having too, if your edge (Cloudflare, a load balancer) adds it as a header. A sudden wave from a hosting provider your users never come from is a strong signal. You don’t need to resolve it yourself.</p>
<h2 id="3-five-patterns-to-watch">3. Five patterns to watch <a class="post-heading-anchor" href="#3-five-patterns-to-watch" aria-hidden="true">#</a></h2>
<p>All of these are cheap to detect once the fields above are in one searchable place. The log lines below are illustrative, using documentation IP ranges.</p>
<p><strong>Login bursts.</strong> <a href="/blog/credential-stuffing-detection">Credential stuffing</a> looks like a lot of <code>POST /v1/auth/login</code> with <code>401</code>s from a small set of IPs, or a slow trickle from many.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">text</span></div><pre><code class="language-text hljs">203.0.113.24  POST /v1/auth/login  401  38ms
203.0.113.24  POST /v1/auth/login  401  41ms
203.0.113.24  POST /v1/auth/login  401  36ms   (x 412 in 10 min)
</code></pre></div>
<p><strong>Sequential ID walking.</strong> One caller requesting <code>/v1/users/1</code>, <code>/2</code>, <code>/3</code>… This is how <a href="/blog/bola-vulnerability-node-api">Broken Object Level Authorization</a> gets found and exploited, and it’s #1 on the <a href="https://owasp.org/API-Security/editions/2023/en/0x11-t10/" target="_blank" rel="noopener noreferrer">OWASP API Security Top 10 (2023)</a>. If the responses are <code>200</code>, you have a data leak, not a probe.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">text</span></div><pre><code class="language-text hljs">198.51.100.7  GET /v1/users/1041  200
198.51.100.7  GET /v1/users/1042  200
198.51.100.7  GET /v1/users/1043  200
</code></pre></div>
<p><strong>Pagination scraping.</strong> <code>GET /v1/products?page=1</code> through <code>page=900</code> at a steady rate, often with a generic or missing user agent. It’s not an exploit, but it costs you compute and can lift your whole catalogue. OWASP lists this class of problem as <a href="https://owasp.org/API-Security/editions/2023/en/0x11-t10/" target="_blank" rel="noopener noreferrer">Unrestricted Resource Consumption (API4:2023)</a>.</p>
<p><strong>404 sweeps.</strong> Requests for paths you never built: <code>/.env</code>, <code>/.git/config</code>, <code>/admin</code>, <code>/wp-login.php</code>. Almost always automated. Mostly harmless on a Node API, until one of them returns <code>200</code>.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">text</span></div><pre><code class="language-text hljs">192.0.2.91  GET /.env            404
192.0.2.91  GET /.git/config     404
192.0.2.91  GET /admin           404
192.0.2.91  GET /api/v1/.env     404
</code></pre></div>
<p><strong>New networks.</strong> Traffic that suddenly arrives from an ASN or country your users have never come from, often a cloud provider. On its own it proves nothing. Combined with any pattern above, it’s the caller to look at first.</p>
<h2 id="4-when-to-block-and-when-to-watch">4. When to block and when to watch <a class="post-heading-anchor" href="#4-when-to-block-and-when-to-watch" aria-hidden="true">#</a></h2>
<p>Not everything that looks automated should be blocked. Some of it is your own monitoring, a partner integration, or a search engine.</p>
<ul>
<li><strong>Watch first.</strong> Log and alert on the patterns above for a week before you block anything. You’ll find the legitimate automation you forgot about.</li>
<li><strong>Rate-limit sensitive routes.</strong> Login, password reset, signup and anything that sends email or SMS. Ten attempts per IP per minute on login is a reasonable start, and it’s the example <a href="https://www.apitraffic.io/docs/waf/policies">ApiTraffic’s own firewall policy docs</a> use.</li>
<li><strong>Block what’s never legitimate.</strong> Paths like <code>/.env</code> on an API that has no such file. IPs you’ve confirmed are abusive.</li>
<li><strong>Allow-list what must never break.</strong> Health checks, your own services, known partners.</li>
</ul>
<p>The goal isn’t zero bot traffic. It’s knowing which bots you have, and making the expensive ones pay a <code>429</code>.</p>
<h2 id="5-do-it-yourself-in-express">5. Do it yourself in Express <a class="post-heading-anchor" href="#5-do-it-yourself-in-express" aria-hidden="true">#</a></h2>
<p>Here’s a minimal version: structured logs with the fields above, a stricter rate limit on login, and a cheap 404-sweep block. It assumes <code>express-rate-limit</code> and that your app runs behind one proxy.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">javascript</span></div><pre><code class="language-javascript hljs"><span class="hljs-keyword">const</span> express = <span class="hljs-built_in">require</span>(<span class="hljs-string">'express'</span>);
<span class="hljs-keyword">const</span> rateLimit = <span class="hljs-built_in">require</span>(<span class="hljs-string">'express-rate-limit'</span>);

<span class="hljs-keyword">const</span> app = <span class="hljs-title">express</span>();
app.<span class="hljs-title">set</span>(<span class="hljs-string">'trust proxy'</span>, <span class="hljs-number">1</span>); <span class="hljs-comment">// trust exactly one proxy hop, so req.ip is the real client</span>
app.<span class="hljs-title">use</span>(express.<span class="hljs-title">json</span>({ <span class="hljs-attr">limit</span>: <span class="hljs-string">'100kb'</span> }));

<span class="hljs-comment">// 1. One structured log line per request</span>
app.<span class="hljs-title">use</span>(<span class="hljs-function">(<span class="hljs-params">req, res, next</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> start = process.<span class="hljs-property">hrtime</span>.<span class="hljs-title">bigint</span>();
  res.<span class="hljs-title">on</span>(<span class="hljs-string">'finish'</span>, <span class="hljs-function">() =&gt;</span> {
    <span class="hljs-variable">console</span>.<span class="hljs-title">log</span>(<span class="hljs-title">JSON</span>.<span class="hljs-title">stringify</span>({
      <span class="hljs-attr">ts</span>: <span class="hljs-keyword">new</span> <span class="hljs-title">Date</span>().<span class="hljs-title">toISOString</span>(),
      <span class="hljs-attr">method</span>: req.<span class="hljs-property">method</span>,
      <span class="hljs-attr">path</span>: req.<span class="hljs-property">originalUrl</span>.<span class="hljs-title">split</span>(<span class="hljs-string">'?'</span>)[<span class="hljs-number">0</span>],
      <span class="hljs-attr">route</span>: req.<span class="hljs-property">route</span> ? req.<span class="hljs-property">baseUrl</span> + req.<span class="hljs-property">route</span>.<span class="hljs-property">path</span> : <span class="hljs-literal">null</span>,
      <span class="hljs-attr">status</span>: res.<span class="hljs-property">statusCode</span>,
      <span class="hljs-attr">latency_ms</span>: <span class="hljs-title">Number</span>(process.<span class="hljs-property">hrtime</span>.<span class="hljs-title">bigint</span>() - start) / <span class="hljs-number">1e6</span>,
      <span class="hljs-attr">ip</span>: req.<span class="hljs-property">ip</span>,
      <span class="hljs-attr">user_id</span>: req.<span class="hljs-property">user</span>?.<span class="hljs-property">id</span> ?? <span class="hljs-literal">null</span>,     <span class="hljs-comment">// set by your auth middleware</span>
      <span class="hljs-attr">ua</span>: req.<span class="hljs-title">get</span>(<span class="hljs-string">'user-agent'</span>) ?? <span class="hljs-literal">null</span>,
      <span class="hljs-attr">req_bytes</span>: <span class="hljs-title">Number</span>(req.<span class="hljs-title">get</span>(<span class="hljs-string">'content-length'</span>) ?? <span class="hljs-number">0</span>),
      <span class="hljs-attr">asn</span>: req.<span class="hljs-title">get</span>(<span class="hljs-string">'x-asn'</span>) ?? <span class="hljs-literal">null</span>,     <span class="hljs-comment">// only if your edge sets and overwrites it</span>
    }));
  });
  <span class="hljs-title">next</span>();
});

<span class="hljs-comment">// 2. Block paths that are never legitimate on this API</span>
<span class="hljs-keyword">const</span> probe = <span class="hljs-regexp">/^\/(\.env|\.git|wp-login\.php|phpmyadmin)/i</span>;
app.<span class="hljs-title">use</span>(<span class="hljs-function">(<span class="hljs-params">req, res, next</span>) =&gt;</span> (probe.<span class="hljs-title">test</span>(req.<span class="hljs-property">path</span>) ? res.<span class="hljs-title">sendStatus</span>(<span class="hljs-number">404</span>) : <span class="hljs-title">next</span>()));

<span class="hljs-comment">// 3. Tighter limit on login</span>
app.<span class="hljs-title">use</span>(<span class="hljs-string">'/v1/auth/login'</span>, <span class="hljs-title">rateLimit</span>({ <span class="hljs-attr">windowMs</span>: <span class="hljs-number">60_000</span>, <span class="hljs-attr">limit</span>: <span class="hljs-number">10</span> }));

app.<span class="hljs-title">use</span>(<span class="hljs-string">'/v1'</span>, apiRouter);
app.<span class="hljs-title">listen</span>(<span class="hljs-number">3000</span>);
</code></pre></div>
<p>Then point your log drain somewhere searchable and write the queries: 401s on login grouped by IP, distinct IDs per caller on <code>/v1/users/:id</code>, 404s per IP per hour.</p>
<p>Two things to get right:</p>
<ul>
<li><strong><code>trust proxy</code>.</strong> If you trust every hop, a caller can write any IP into <code>X-Forwarded-For</code> and your logs and rate limits will believe it. Trust only the proxies you actually run behind.</li>
<li><strong>The body.</strong> This logger leaves bodies out on purpose. If you add them, redact first (see section 7).</li>
</ul>
<p>This works. The cost is that you now maintain a logger, a log pipeline, the queries and the alerting, and <a href="/blog/nodejs-rate-limiting">the rate limit counts per process unless you add a shared store</a>.</p>
<h2 id="6-or-do-it-with-apitraffic">6. Or do it with ApiTraffic <a class="post-heading-anchor" href="#6-or-do-it-with-apitraffic" aria-hidden="true">#</a></h2>
<p>ApiTraffic captures every request and response from one middleware, and the same SDK carries a firewall. The <a href="https://www.apitraffic.io/docs/quickstart">quickstart</a> takes about five minutes.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">bash</span></div><pre><code class="language-bash hljs">npm install @apitraffic/node
</code></pre></div>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">javascript</span></div><pre><code class="language-javascript hljs"><span class="hljs-keyword">const</span> apitraffic = <span class="hljs-built_in">require</span>(<span class="hljs-string">'@apitraffic/node'</span>);

app.<span class="hljs-title">use</span>(express.<span class="hljs-title">json</span>());
app.<span class="hljs-title">use</span>(apitraffic.<span class="hljs-title">express</span>()); <span class="hljs-comment">// after body parsers, before routes</span>
app.<span class="hljs-title">use</span>(<span class="hljs-string">'/v1'</span>, apiRouter);
</code></pre></div>
<p>It reads <code>API_TRAFFIC_TOKEN</code> and <code>API_TRAFFIC_BUCKET</code> from the environment. To tie requests to accounts, tag them in a route or your auth middleware:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">javascript</span></div><pre><code class="language-javascript hljs">apitraffic.<span class="hljs-title">tag</span>(<span class="hljs-string">'userId'</span>, <span class="hljs-title">String</span>(req.<span class="hljs-property">user</span>.<span class="hljs-property">id</span>));
</code></pre></div>
<p>The patterns above become <a href="https://www.apitraffic.io/docs/traffic/search">searches in the request log</a>:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">text</span></div><pre><code class="language-text hljs">url_path == "/v1/auth/login" AND status_code == 401
status_code == 404 AND url_path like "/.env*"
tag:userId == "1042" AND status_code &gt;= 400
</code></pre></div>
<p>And the blocking side lives in a firewall policy, pushed to your servers without a deploy:</p>
<ul>
<li><strong>Rate limits</strong> per IP, header (an API key, say) or path. Over the limit gets a <code>429</code> with <code>Retry-After</code>.</li>
<li><strong>Access lists</strong> to allow or deny by IP or CIDR, country, ASN (from a header your edge sets), user agent or path.</li>
<li><strong>Behavioral protection</strong> that scores each caller over a window on exactly these signals: distinct object IDs per endpoint, 401/403 counts, 404 counts and distinct endpoints. Callers who cross the threshold get a temporary deny on every server.</li>
<li><strong>Monitor mode</strong> first, so you can see what a policy <em>would</em> block before it blocks anything.</li>
</ul>
<h2 id="7-redact-before-it-leaves-your-server">7. Redact before it leaves your server <a class="post-heading-anchor" href="#7-redact-before-it-leaves-your-server" aria-hidden="true">#</a></h2>
<p>Capturing more detail means capturing more you shouldn’t keep: tokens, passwords, emails, card numbers.</p>
<p>If you roll your own logger, never log raw <code>Authorization</code> or <code>Cookie</code> headers, strip password and token fields from bodies before you write them, and keep body logging off for routes that handle credentials. More on this in <a href="/blog/redact-sensitive-data-logs">redacting sensitive data in logs</a>.</p>
<p>In ApiTraffic, credential headers such as <code>Authorization</code> are always masked. <a href="https://www.apitraffic.io/docs/data/redactions">Redaction rules</a> set in the dashboard can run at the API, inside your process, so the value never leaves your server. They’re pushed to connected servers when you save, with no deploy.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="what-should-a-small-team-log-on-every-api-request">What should a small team log on every API request? <a class="post-heading-anchor" href="#what-should-a-small-team-log-on-every-api-request" aria-hidden="true">#</a></h3>
<p>Method, path (raw and the route template), status, latency, IP, a user or API key ID, user agent and request size. Add the ASN if your edge sets it as a header. Log an ID for the caller, never the token.</p>
<h3 id="how-do-i-spot-credential-stuffing-in-my-logs">How do I spot credential stuffing in my logs? <a class="post-heading-anchor" href="#how-do-i-spot-credential-stuffing-in-my-logs" aria-hidden="true">#</a></h3>
<p>Look for a lot of <code>POST /v1/auth/login</code> requests returning <code>401</code>, either from a small set of IPs or as a slow trickle from many. Group 401s on the login route by IP to see it.</p>
<h3 id="should-i-block-bot-traffic-as-soon-as-i-see-it">Should I block bot traffic as soon as I see it? <a class="post-heading-anchor" href="#should-i-block-bot-traffic-as-soon-as-i-see-it" aria-hidden="true">#</a></h3>
<p>No. Log and alert on the patterns for a week first, because some automation is your own monitoring, a partner integration or a search engine. Then rate-limit sensitive routes, block what is never legitimate and allow-list what must never break.</p>
<h3 id="is-it-safe-to-log-request-bodies">Is it safe to log request bodies? <a class="post-heading-anchor" href="#is-it-safe-to-log-request-bodies" aria-hidden="true">#</a></h3>
<p>Only after redacting. Never log raw <code>Authorization</code> or <code>Cookie</code> headers, strip password and token fields from bodies before you write them, and keep body logging off for routes that handle credentials.</p>
<h2 id="the-short-version">The short version <a class="post-heading-anchor" href="#the-short-version" aria-hidden="true">#</a></h2>
<p>Log method, path, status, latency, IP, user ID, user agent and size on every request. Watch login bursts, ID walking, pagination scraping, 404 sweeps and new networks. Watch before you block. Rate-limit what’s sensitive.</p>
<p><strong>Start free and see the last hour of what’s hitting your API: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=ai-era&amp;utm_content=p03">apitraffic.io</a></strong></p>
]]></content:encoded>
    </item>
    <item>
      <title>Deploy markers: tying a latency regression to the release that caused it</title>
      <link>https://www.apitraffic.io/blog/deploy-markers-regressions</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/deploy-markers-regressions</guid>
      <pubDate>Tue, 13 Oct 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>p95 doubled, and there were three deploys and a flag flip since yesterday. Deploy markers and a version field on every log line turn &quot;which release did this&quot; into a five-minute query. Here is how to set both up for a Node API.</description>
      <content:encoded><![CDATA[<p>On Tuesday afternoon, p95 latency on <code>/v1/search</code> doubled. Since Monday there had been three deploys, a feature flag change and a dependency bump. Everyone had a theory. Nobody had a timeline.</p>
<p>Deploy markers fix that. A deploy marker is a timestamped record of each release (service, environment, commit SHA, who shipped it) drawn on the same charts as your latency and error rates. Add one more thing, the running version on every request log line, and “which release did this” becomes a query: compare p95 per endpoint for the old version and the new one. This post shows how to do both for a Node API in an afternoon, from the build step to a before-and-after comparison script.</p>
<h2 id="what-is-a-deploy-marker">What is a deploy marker? <a class="post-heading-anchor" href="#what-is-a-deploy-marker" aria-hidden="true">#</a></h2>
<p>A deploy marker is an event, not a metric. It needs a handful of fields:</p>
<div class="post-table"><table>
<thead>
<tr>
<th>Field</th>
<th>Example</th>
<th>Why</th>
</tr>
</thead>
<tbody>
<tr>
<td>time</td>
<td><code>2026-10-06T14:02:11Z</code></td>
<td>Lines the marker up with the charts</td>
</tr>
<tr>
<td>service</td>
<td><code>orders-api</code></td>
<td>Several services deploy independently</td>
</tr>
<tr>
<td>environment</td>
<td><code>production</code></td>
<td>Staging deploys should not clutter prod charts</td>
</tr>
<tr>
<td>version</td>
<td><code>a1b2c3d</code> (commit SHA)</td>
<td>Links straight to the diff</td>
</tr>
<tr>
<td>type</td>
<td><code>deploy</code>, <code>rollback</code>, <code>config</code></td>
<td>A flag flip changes behavior too</td>
</tr>
<tr>
<td>who</td>
<td><code>jane</code> or <code>github_actions</code></td>
<td>Who to ask</td>
</tr>
</tbody>
</table></div>
<p>Most monitoring tools have a place for these: Grafana annotations, Sentry releases, Datadog deployment tracking and Honeycomb markers all do the same job with different names. The tool matters less than doing it automatically on every deploy.</p>
<h2 id="how-to-add-deploy-markers-to-a-node-api">How to add deploy markers to a Node API <a class="post-heading-anchor" href="#how-to-add-deploy-markers-to-a-node-api" aria-hidden="true">#</a></h2>
<h3 id="step-1-bake-the-commit-sha-into-the-build">Step 1: bake the commit SHA into the build <a class="post-heading-anchor" href="#step-1-bake-the-commit-sha-into-the-build" aria-hidden="true">#</a></h3>
<p>Your running process should know which commit it is. Pass it in at build time:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">dockerfile</span></div><pre><code class="language-dockerfile hljs"><span class="hljs-comment"># Dockerfile</span>
<span class="hljs-keyword">FROM</span> node:<span class="hljs-number">24</span>-bookworm-slim
<span class="hljs-keyword">ARG</span> GIT_SHA=unknown
<span class="hljs-keyword">ENV</span> GIT_SHA=$GIT_SHA
<span class="hljs-keyword">WORKDIR</span><span class="language-bash"> /app</span>
<span class="hljs-keyword">COPY</span><span class="language-bash"> package*.json ./</span>
<span class="hljs-keyword">RUN</span><span class="language-bash"> npm ci --omit=dev</span>
<span class="hljs-keyword">COPY</span><span class="language-bash"> . .</span>
<span class="hljs-keyword">CMD</span><span class="language-bash"> [<span class="hljs-string">"node"</span>, <span class="hljs-string">"server.js"</span>]</span>
</code></pre></div>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">bash</span></div><pre><code class="language-bash hljs">docker build --build-arg GIT_SHA=$(git rev-parse --short HEAD) -t orders-api .
</code></pre></div>
<p>Some platforms set it for you. <a href="https://render.com/docs/environment-variables" target="_blank" rel="noopener noreferrer">Render exposes <code>RENDER_GIT_COMMIT</code></a> at build time and runtime. In GitHub Actions, the commit is <a href="https://docs.github.com/en/actions/reference/workflows-and-actions/contexts" target="_blank" rel="noopener noreferrer"><code>${{ github.sha }}</code></a>.</p>
<h3 id="step-2-put-the-version-on-every-log-line">Step 2: put the version on every log line <a class="post-heading-anchor" href="#step-2-put-the-version-on-every-log-line" aria-hidden="true">#</a></h3>
<p>A marker tells you when a release started. During a rolling deploy, old and new instances serve traffic side by side for a while, so the marker alone cannot tell you which version handled a slow request. The version on each log line can:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// logger.js</span>
<span class="hljs-keyword">import</span> pino <span class="hljs-keyword">from</span> <span class="hljs-string">'pino'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> version = process.<span class="hljs-property">env</span>.<span class="hljs-property">GIT_SHA</span> ?? process.<span class="hljs-property">env</span>.<span class="hljs-property">RENDER_GIT_COMMIT</span>?.<span class="hljs-title">slice</span>(<span class="hljs-number">0</span>, <span class="hljs-number">7</span>) ?? <span class="hljs-string">'dev'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> log = <span class="hljs-title">pino</span>({ <span class="hljs-attr">base</span>: { <span class="hljs-attr">service</span>: <span class="hljs-string">'orders-api'</span>, version } });
log.<span class="hljs-title">info</span>(<span class="hljs-string">'process started'</span>);
</code></pre></div>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// server.js (Express)</span>
<span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> { log } <span class="hljs-keyword">from</span> <span class="hljs-string">'./logger.js'</span>;

<span class="hljs-keyword">const</span> app = <span class="hljs-title">express</span>();

app.<span class="hljs-title">use</span>(<span class="hljs-function">(<span class="hljs-params">req, res, next</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> start = process.<span class="hljs-property">hrtime</span>.<span class="hljs-title">bigint</span>();
  res.<span class="hljs-title">on</span>(<span class="hljs-string">'finish'</span>, <span class="hljs-function">() =&gt;</span> {
    log.<span class="hljs-title">info</span>({
      <span class="hljs-attr">method</span>: req.<span class="hljs-property">method</span>,
      <span class="hljs-attr">route</span>: req.<span class="hljs-property">route</span>?.<span class="hljs-property">path</span> ? <span class="hljs-string">`<span class="hljs-subst">${req.baseUrl}</span><span class="hljs-subst">${req.route.path}</span>`</span> : <span class="hljs-string">'unmatched'</span>,
      <span class="hljs-attr">status</span>: res.<span class="hljs-property">statusCode</span>,
      <span class="hljs-attr">ms</span>: <span class="hljs-title">Number</span>(process.<span class="hljs-property">hrtime</span>.<span class="hljs-title">bigint</span>() - start) / <span class="hljs-number">1e6</span>,
    }, <span class="hljs-string">'request'</span>);
  });
  <span class="hljs-title">next</span>();
});
</code></pre></div>
<p>Use the route pattern, not the raw URL, or every ID becomes its own endpoint.</p>
<h3 id="step-3-send-the-marker-from-ci-not-from-memory">Step 3: send the marker from CI, not from memory <a class="post-heading-anchor" href="#step-3-send-the-marker-from-ci-not-from-memory" aria-hidden="true">#</a></h3>
<p>The most useful markers are the ones nobody has to remember. Add a step at the end of your deploy job. This example writes a <a href="https://grafana.com/docs/grafana/latest/developers/http_api/annotations/" target="_blank" rel="noopener noreferrer">Grafana annotation</a>; swap the URL and body for whatever tool you use:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">yaml</span></div><pre><code class="language-yaml hljs"><span class="hljs-comment"># .github/workflows/deploy.yml (last step of the deploy job)</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">Record</span> <span class="hljs-string">deploy</span> <span class="hljs-string">marker</span>
  <span class="hljs-attr">if:</span> <span class="hljs-string">success()</span>
  <span class="hljs-attr">run:</span> <span class="hljs-string">|
    curl -sf -X POST "$GRAFANA_URL/api/annotations" \
      -H "Authorization: Bearer $GRAFANA_TOKEN" \
      -H "Content-Type: application/json" \
      -d "{\"time\": $(date +%s000), \"tags\": [\"deploy\", \"orders-api\", \"production\"],
           \"text\": \"orders-api ${GITHUB_SHA::7} by ${GITHUB_ACTOR}\"}"
</span>  <span class="hljs-attr">env:</span>
    <span class="hljs-attr">GRAFANA_URL:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.GRAFANA_URL</span> <span class="hljs-string">}}</span>
    <span class="hljs-attr">GRAFANA_TOKEN:</span> <span class="hljs-string">${{</span> <span class="hljs-string">secrets.GRAFANA_TOKEN</span> <span class="hljs-string">}}</span>
</code></pre></div>
<p>Record rollbacks the same way, with a name that says “rollback”. Record feature flag and config changes as markers too: some of the worst regressions ship without a deploy.</p>
<h2 id="compare-p95-before-and-after-a-deploy">Compare p95 before and after a deploy <a class="post-heading-anchor" href="#compare-p95-before-and-after-a-deploy" aria-hidden="true">#</a></h2>
<p>With the version on every log line, you can compare releases directly instead of comparing time windows. This script reads NDJSON request logs and prints p95 per endpoint for two versions:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// compare-versions.mjs -- node compare-versions.mjs logs.ndjson a1b2c3d e4f5a6b</span>
<span class="hljs-keyword">import</span> { createReadStream } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:fs'</span>;
<span class="hljs-keyword">import</span> { createInterface } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:readline'</span>;

<span class="hljs-keyword">const</span> [file, before, after] = process.<span class="hljs-property">argv</span>.<span class="hljs-title">slice</span>(<span class="hljs-number">2</span>);
<span class="hljs-keyword">const</span> <span class="hljs-variable">MIN_SAMPLES</span> = <span class="hljs-number">200</span>;
<span class="hljs-keyword">const</span> groups = <span class="hljs-keyword">new</span> <span class="hljs-title">Map</span>();   <span class="hljs-comment">// "GET /v1/search" -&gt; { [version]: number[] }</span>

<span class="hljs-keyword">for</span> <span class="hljs-title">await</span> (<span class="hljs-keyword">const</span> line <span class="hljs-keyword">of</span> <span class="hljs-title">createInterface</span>({ <span class="hljs-attr">input</span>: <span class="hljs-title">createReadStream</span>(file) })) {
  <span class="hljs-keyword">if</span> (!line.<span class="hljs-title">includes</span>(<span class="hljs-string">'"request"'</span>)) <span class="hljs-keyword">continue</span>;
  <span class="hljs-keyword">const</span> r = <span class="hljs-title">JSON</span>.<span class="hljs-title">parse</span>(line);
  <span class="hljs-keyword">if</span> (r.<span class="hljs-property">version</span> !== before &amp;&amp; r.<span class="hljs-property">version</span> !== after) <span class="hljs-keyword">continue</span>;
  <span class="hljs-keyword">const</span> key = <span class="hljs-string">`<span class="hljs-subst">${r.method}</span> <span class="hljs-subst">${r.route}</span>`</span>;
  <span class="hljs-keyword">const</span> g = groups.<span class="hljs-title">get</span>(key) ?? { [before]: [], [after]: [] };
  g[r.<span class="hljs-property">version</span>].<span class="hljs-title">push</span>(r.<span class="hljs-property">ms</span>);
  groups.<span class="hljs-title">set</span>(key, g);
}

<span class="hljs-keyword">const</span> <span class="hljs-title">p95</span> = (<span class="hljs-params">xs</span>) =&gt; {
  <span class="hljs-keyword">const</span> s = [...xs].<span class="hljs-title">sort</span>(<span class="hljs-function">(<span class="hljs-params">a, b</span>) =&gt;</span> a - b);
  <span class="hljs-keyword">return</span> s[<span class="hljs-title">Math</span>.<span class="hljs-title">ceil</span>(<span class="hljs-number">0.95</span> * s.<span class="hljs-property">length</span>) - <span class="hljs-number">1</span>];
};

<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [key, g] <span class="hljs-keyword">of</span> groups) {
  <span class="hljs-keyword">if</span> (g[before].<span class="hljs-property">length</span> &lt; <span class="hljs-variable">MIN_SAMPLES</span> || g[after].<span class="hljs-property">length</span> &lt; <span class="hljs-variable">MIN_SAMPLES</span>) <span class="hljs-keyword">continue</span>;
  <span class="hljs-keyword">const</span> [a, b] = [<span class="hljs-title">p95</span>(g[before]), <span class="hljs-title">p95</span>(g[after])];
  <span class="hljs-keyword">const</span> flag = b &gt; a * <span class="hljs-number">1.25</span> ? <span class="hljs-string">'  &lt;-- regression'</span> : <span class="hljs-string">''</span>;
  <span class="hljs-variable">console</span>.<span class="hljs-title">log</span>(<span class="hljs-string">`<span class="hljs-subst">${key.padEnd(<span class="hljs-number">30</span>)}</span> p95 <span class="hljs-subst">${a.toFixed(<span class="hljs-number">0</span>)}</span>ms -&gt; <span class="hljs-subst">${b.toFixed(<span class="hljs-number">0</span>)}</span>ms<span class="hljs-subst">${flag}</span>`</span>);
}
</code></pre></div>
<p>Why percentiles and not averages: one slow query path can double p95 while the average barely moves. Our post on <a href="/blog/api-latency-percentiles">reading p50, p95 and p99</a> covers how to read these numbers without fooling yourself.</p>
<h2 id="why-before-and-after-comparisons-mislead">Why before-and-after comparisons mislead <a class="post-heading-anchor" href="#why-before-and-after-comparisons-mislead" aria-hidden="true">#</a></h2>
<ul>
<li><strong>Traffic mix.</strong> A deploy at 9am Monday compared with Sunday night is comparing two different workloads. Compare by version during the overlap, or against the same hour a day or a week earlier.</li>
<li><strong>Warm-up.</strong> New processes start with cold caches, connection pools and JIT. Skip the first few minutes after each instance starts.</li>
<li><strong>Small samples.</strong> An endpoint with 40 calls an hour has a noisy p95. The <code>MIN_SAMPLES</code> guard is there for a reason.</li>
<li><strong>Bundled changes.</strong> If one deploy contains a dependency bump, a query change and a new index, the marker tells you which deploy, not which change. Smaller deploys make markers more useful.</li>
</ul>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<ol>
<li>Bake the commit SHA into the image and log it on every request.</li>
<li>Send a marker from CI on every deploy and rollback.</li>
<li>Record flag and config changes as markers too.</li>
<li>When latency moves, compare p95 per endpoint by version before you debate theories.</li>
</ol>
<p>A one-person team can get steps 1 and 2 done in an hour and stop guessing. A ten-person team should make the CI step part of the shared deploy workflow and agree on marker names, so markers from different services read the same way. If latency regressions are eating into your reliability targets, connect this to your <a href="/blog/error-budget-small-api-team">error budget</a> and to what you <a href="/blog/api-alerting-best-practices">page on</a>.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="how-long-should-i-wait-after-a-deploy-before-comparing-latency">How long should I wait after a deploy before comparing latency? <a class="post-heading-anchor" href="#how-long-should-i-wait-after-a-deploy-before-comparing-latency" aria-hidden="true">#</a></h3>
<p>Long enough for a few hundred requests per endpoint you care about, after warm-up. For a busy API that is 15 to 30 minutes; for a quiet one, compare against the same window on the previous day.</p>
<h3 id="should-feature-flag-changes-be-deploy-markers">Should feature flag changes be deploy markers? <a class="post-heading-anchor" href="#should-feature-flag-changes-be-deploy-markers" aria-hidden="true">#</a></h3>
<p>Yes, as their own type. A flag flip can change code paths as much as a deploy, and it is the change people forget to mention.</p>
<h3 id="do-i-need-a-commercial-tool-for-deploy-markers">Do I need a commercial tool for deploy markers? <a class="post-heading-anchor" href="#do-i-need-a-commercial-tool-for-deploy-markers" aria-hidden="true">#</a></h3>
<p>No. A log line with the version and a CI step that writes an annotation in the tool you already use is enough to start.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not build the comparison yourself, record each deploy as an <a href="https://www.apitraffic.io/docs/events">event in ApiTraffic</a> from your pipeline, with the commit in its metadata:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">bash</span></div><pre><code class="language-bash hljs">curl -s -X POST <span class="hljs-string">"https://api.apitraffic.io/v1/accounts/<span class="hljs-variable">${APITRAFFIC_ACCOUNT_SID}</span>/events"</span> \
  -H <span class="hljs-string">"Authorization: Bearer <span class="hljs-variable">${APITRAFFIC_API_TOKEN}</span>"</span> \
  -H <span class="hljs-string">"Content-Type: application/json"</span> \
  -d <span class="hljs-string">"{\"type\": \"deployment\", \"name\": \"orders-api <span class="hljs-variable">${GITHUB_SHA::7}</span>\",
       \"metadata\": {\"commitSha\": \"<span class="hljs-variable">${GITHUB_SHA}</span>\"}}"</span>
</code></pre></div>
<p>Markers appear on the throughput and response time charts. <a href="https://www.apitraffic.io/docs/traffic/dashboard">Regressions</a> compares each endpoint’s p95 before and after a deploy event, and the endpoint’s <a href="https://www.apitraffic.io/docs/apm/analytics">Time Breakdown and Top Operations</a> show whether the new time went to a query, an N+1 pattern or an outbound call.</p>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20261013-deploy-markers-regressions">apitraffic.io</a></p>
<p>Sources: <a href="https://render.com/docs/environment-variables" target="_blank" rel="noopener noreferrer">Render docs, “Default Environment Variables”</a>; <a href="https://docs.github.com/en/actions/reference/workflows-and-actions/contexts" target="_blank" rel="noopener noreferrer">GitHub Docs, “Contexts reference” (github.sha)</a>; <a href="https://grafana.com/docs/grafana/latest/developers/http_api/annotations/" target="_blank" rel="noopener noreferrer">Grafana docs, “Annotations HTTP API”</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Node.js large file upload handling without running out of memory</title>
      <link>https://www.apitraffic.io/blog/nodejs-large-file-upload</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/nodejs-large-file-upload</guid>
      <pubDate>Mon, 05 Oct 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>One 2 GB upload should not take your API down with it. Here is how to stream uploads in Express and Fastify, when to use presigned URLs, and which parsers to patch.</description>
      <content:encoded><![CDATA[<p>A customer uploads a 2 GB video, the API process climbs past its memory limit, and the platform kills it, taking every other in-flight request with it. Most Node.js large file upload bugs come down to one line that buffers the whole file in memory before doing anything with it.</p>
<p>The short answer: never hold an upload in memory. Stream it to disk or object storage with <code>stream.pipeline</code>, set explicit limits on file size, file count and fields, and for big files skip your API entirely with presigned URLs to S3-compatible storage. And check your multipart parser version, because 2026 has brought a run of denial-of-service fixes in multer, @fastify/multipart and @fastify/busboy.</p>
<h2 id="why-a-node-js-large-file-upload-crashes-your-api">Why a Node.js large file upload crashes your API <a class="post-heading-anchor" href="#why-a-node-js-large-file-upload-crashes-your-api" aria-hidden="true">#</a></h2>
<p>Three patterns cause most incidents:</p>
<ul>
<li>Buffering. Multer’s <code>memoryStorage()</code>, <code>@fastify/multipart</code>’s <code>toBuffer()</code> and hand-written <code>Buffer.concat</code> loops all keep the full file in memory. Ten concurrent 500 MB uploads need 5 GB.</li>
<li>No limits. Without <code>fileSize</code>, <code>files</code> and <code>fields</code> limits, a single request decides how much work your server does.</li>
<li>Long requests on a shared process. A slow upload ties up a connection for minutes. Synchronous work on the data, such as hashing a buffer in one go, blocks the event loop for everyone; see <a href="/blog/nodejs-event-loop-lag">event loop lag</a>.</li>
</ul>
<p>Defaults help only for non-file bodies. Express’s <code>express.json()</code> limits JSON to 100 KB by default and Fastify’s <code>bodyLimit</code> defaults to 1 MiB, but multipart parsing is configured separately, in the multipart library.</p>
<h2 id="upload-strategies-compared">Upload strategies compared <a class="post-heading-anchor" href="#upload-strategies-compared" aria-hidden="true">#</a></h2>
<div class="post-table"><table>
<thead>
<tr>
<th>Strategy</th>
<th>Memory per upload</th>
<th>Max practical size</th>
<th>Effort</th>
</tr>
</thead>
<tbody>
<tr>
<td>Buffer in memory</td>
<td>Whole file</td>
<td>Small images</td>
<td>Lowest, riskiest</td>
</tr>
<tr>
<td>Stream to local disk</td>
<td>A few KB of buffers</td>
<td>Disk space</td>
<td>Low</td>
</tr>
<tr>
<td>Stream through API to S3</td>
<td>A few MB of part buffers</td>
<td>Many GB</td>
<td>Medium</td>
</tr>
<tr>
<td>Presigned URL, client to S3</td>
<td>None on your API</td>
<td>5 GB per PUT, more with multipart</td>
<td>Medium</td>
</tr>
</tbody>
</table></div>
<h2 id="how-to-stream-file-uploads-in-express-with-busboy">How to stream file uploads in Express with busboy <a class="post-heading-anchor" href="#how-to-stream-file-uploads-in-express-with-busboy" aria-hidden="true">#</a></h2>
<p>Multer is fine with <code>diskStorage</code> and limits, but using busboy directly shows exactly what happens. This Express 5 route streams one file to disk and enforces a size limit:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> busboy <span class="hljs-keyword">from</span> <span class="hljs-string">'busboy'</span>;
<span class="hljs-keyword">import</span> { createWriteStream } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:fs'</span>;
<span class="hljs-keyword">import</span> { unlink } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:fs/promises'</span>;
<span class="hljs-keyword">import</span> { pipeline } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:stream/promises'</span>;
<span class="hljs-keyword">import</span> { randomUUID } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:crypto'</span>;
<span class="hljs-keyword">import</span> path <span class="hljs-keyword">from</span> <span class="hljs-string">'node:path'</span>;

<span class="hljs-keyword">const</span> app = <span class="hljs-title">express</span>();
<span class="hljs-keyword">const</span> <span class="hljs-variable">UPLOAD_DIR</span> = <span class="hljs-string">'/var/uploads'</span>;
<span class="hljs-keyword">const</span> <span class="hljs-variable">MAX_BYTES</span> = <span class="hljs-number">500</span> * <span class="hljs-number">1024</span> * <span class="hljs-number">1024</span>;

app.<span class="hljs-title">post</span>(<span class="hljs-string">'/v1/uploads'</span>, requireAuth, <span class="hljs-function">(<span class="hljs-params">req, res, next</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> bb = <span class="hljs-title">busboy</span>({
    <span class="hljs-attr">headers</span>: req.<span class="hljs-property">headers</span>,
    <span class="hljs-attr">limits</span>: { <span class="hljs-attr">fileSize</span>: <span class="hljs-variable">MAX_BYTES</span>, <span class="hljs-attr">files</span>: <span class="hljs-number">1</span>, <span class="hljs-attr">fields</span>: <span class="hljs-number">5</span>, <span class="hljs-attr">fieldSize</span>: <span class="hljs-number">1024</span> },
  });
  <span class="hljs-keyword">let</span> saved = <span class="hljs-literal">null</span>;
  <span class="hljs-keyword">const</span> writes = [];

  bb.<span class="hljs-title">on</span>(<span class="hljs-string">'file'</span>, <span class="hljs-function">(<span class="hljs-params">name, file, info</span>) =&gt;</span> {
    <span class="hljs-comment">// Never trust info.filename for the path</span>
    <span class="hljs-keyword">const</span> dest = path.<span class="hljs-title">join</span>(<span class="hljs-variable">UPLOAD_DIR</span>, <span class="hljs-title">randomUUID</span>());
    <span class="hljs-keyword">const</span> write = <span class="hljs-title">pipeline</span>(file, <span class="hljs-title">createWriteStream</span>(dest))
      .<span class="hljs-title">then</span>(<span class="hljs-function">() =&gt;</span> {
        <span class="hljs-keyword">if</span> (file.<span class="hljs-property">truncated</span>) {
          <span class="hljs-keyword">throw</span> <span class="hljs-title">Object</span>.<span class="hljs-title">assign</span>(<span class="hljs-keyword">new</span> <span class="hljs-title">Error</span>(<span class="hljs-string">'file too large'</span>), { <span class="hljs-attr">status</span>: <span class="hljs-number">413</span> });
        }
        saved = { dest, <span class="hljs-attr">type</span>: info.<span class="hljs-property">mimeType</span> };
      })
      .<span class="hljs-title">catch</span>(<span class="hljs-title">async</span> (err) =&gt; {
        <span class="hljs-keyword">await</span> <span class="hljs-title">unlink</span>(dest).<span class="hljs-title">catch</span>(<span class="hljs-function">() =&gt;</span> {});
        <span class="hljs-keyword">throw</span> err;
      });
    write.<span class="hljs-title">catch</span>(<span class="hljs-function">() =&gt;</span> {}); <span class="hljs-comment">// awaited on 'close'; avoids an unhandled rejection</span>
    writes.<span class="hljs-title">push</span>(write);
  });

  bb.<span class="hljs-title">on</span>(<span class="hljs-string">'close'</span>, <span class="hljs-title">async</span> () =&gt; {
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">await</span> <span class="hljs-title">Promise</span>.<span class="hljs-title">all</span>(writes);
      <span class="hljs-keyword">if</span> (!saved) <span class="hljs-keyword">return</span> res.<span class="hljs-title">status</span>(<span class="hljs-number">400</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'no_file'</span> });
      res.<span class="hljs-title">status</span>(<span class="hljs-number">201</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">id</span>: path.<span class="hljs-title">basename</span>(saved.<span class="hljs-property">dest</span>) });
    } <span class="hljs-keyword">catch</span> (err) {
      <span class="hljs-title">next</span>(err);
    }
  });
  bb.<span class="hljs-title">on</span>(<span class="hljs-string">'error'</span>, next);
  req.<span class="hljs-title">on</span>(<span class="hljs-string">'close'</span>, <span class="hljs-function">() =&gt;</span> { <span class="hljs-keyword">if</span> (!req.<span class="hljs-property">complete</span>) bb.<span class="hljs-title">destroy</span>(); });
  req.<span class="hljs-title">pipe</span>(bb);
});
</code></pre></div>
<p>The details that matter: the file is never in memory as a whole, the filename on disk is random, a truncated file is deleted, and an aborted request tears down the parser. Partial files left behind by aborted uploads are exactly what several 2026 advisories fixed in libraries, so test your own cleanup by killing <code>curl</code> mid-upload.</p>
<h2 id="how-to-handle-file-uploads-in-fastify-with-fastify-multipart">How to handle file uploads in Fastify with @fastify/multipart <a class="post-heading-anchor" href="#how-to-handle-file-uploads-in-fastify-with-fastify-multipart" aria-hidden="true">#</a></h2>
<p><code>@fastify/multipart</code> defaults to a 1 MiB <code>fileSize</code> and 1,000 <code>parts</code>. Raise the file limit deliberately, per route if needed:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> <span class="hljs-title">Fastify</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'fastify'</span>;
<span class="hljs-keyword">import</span> multipart <span class="hljs-keyword">from</span> <span class="hljs-string">'@fastify/multipart'</span>;
<span class="hljs-keyword">import</span> { createWriteStream } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:fs'</span>;
<span class="hljs-keyword">import</span> { unlink } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:fs/promises'</span>;
<span class="hljs-keyword">import</span> { pipeline } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:stream/promises'</span>;
<span class="hljs-keyword">import</span> { randomUUID } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:crypto'</span>;

<span class="hljs-keyword">const</span> app = <span class="hljs-title">Fastify</span>({ <span class="hljs-attr">logger</span>: <span class="hljs-literal">true</span> });
<span class="hljs-keyword">await</span> app.<span class="hljs-title">register</span>(multipart, {
  <span class="hljs-attr">limits</span>: { <span class="hljs-attr">fileSize</span>: <span class="hljs-number">500</span> * <span class="hljs-number">1024</span> * <span class="hljs-number">1024</span>, <span class="hljs-attr">files</span>: <span class="hljs-number">1</span>, <span class="hljs-attr">fields</span>: <span class="hljs-number">5</span> },
});

app.<span class="hljs-title">post</span>(<span class="hljs-string">'/v1/uploads'</span>, <span class="hljs-title">async</span> (request, reply) =&gt; {
  <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> request.<span class="hljs-title">file</span>();
  <span class="hljs-keyword">if</span> (!data) <span class="hljs-keyword">return</span> reply.<span class="hljs-title">code</span>(<span class="hljs-number">400</span>).<span class="hljs-title">send</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'no_file'</span> });
  <span class="hljs-keyword">const</span> dest = <span class="hljs-string">`/var/uploads/<span class="hljs-subst">${randomUUID()}</span>`</span>;
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">await</span> <span class="hljs-title">pipeline</span>(data.<span class="hljs-property">file</span>, <span class="hljs-title">createWriteStream</span>(dest));
  } <span class="hljs-keyword">catch</span> (err) {
    <span class="hljs-keyword">await</span> <span class="hljs-title">unlink</span>(dest).<span class="hljs-title">catch</span>(<span class="hljs-function">() =&gt;</span> {});
    <span class="hljs-keyword">if</span> (data.<span class="hljs-property">file</span>.<span class="hljs-property">truncated</span>) <span class="hljs-keyword">return</span> reply.<span class="hljs-title">code</span>(<span class="hljs-number">413</span>).<span class="hljs-title">send</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'too_large'</span> });
    <span class="hljs-keyword">throw</span> err;
  }
  <span class="hljs-keyword">return</span> reply.<span class="hljs-title">code</span>(<span class="hljs-number">201</span>).<span class="hljs-title">send</span>({ <span class="hljs-attr">id</span>: dest.<span class="hljs-title">split</span>(<span class="hljs-string">'/'</span>).<span class="hljs-title">pop</span>() });
});
</code></pre></div>
<p>Avoid <code>data.toBuffer()</code> for anything large; <a href="https://github.com/fastify/fastify-multipart" target="_blank" rel="noopener noreferrer">the plugin’s own README</a> tells you to be careful with it.</p>
<h2 id="presigned-urls-keep-large-uploads-off-your-api">Presigned URLs: keep large uploads off your API <a class="post-heading-anchor" href="#presigned-urls-keep-large-uploads-off-your-api" aria-hidden="true">#</a></h2>
<p>For files in the hundreds of megabytes or more, the best upload is one your API never touches. The API authorizes the upload and returns a short-lived URL, and the client sends the file straight to storage:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> { S3Client, <span class="hljs-title">PutObjectCommand</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'@aws-sdk/client-s3'</span>;
<span class="hljs-keyword">import</span> { getSignedUrl } <span class="hljs-keyword">from</span> <span class="hljs-string">'@aws-sdk/s3-request-presigner'</span>;
<span class="hljs-keyword">import</span> { randomUUID } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:crypto'</span>;

<span class="hljs-keyword">const</span> s3 = <span class="hljs-keyword">new</span> <span class="hljs-title">S3Client</span>({});

app.<span class="hljs-title">post</span>(<span class="hljs-string">'/v1/uploads/presign'</span>, requireAuth, <span class="hljs-title">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> key = <span class="hljs-string">`uploads/<span class="hljs-subst">${req.user.accountId}</span>/<span class="hljs-subst">${randomUUID()}</span>`</span>;
  <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">await</span> <span class="hljs-title">getSignedUrl</span>(
    s3,
    <span class="hljs-keyword">new</span> <span class="hljs-title">PutObjectCommand</span>({ <span class="hljs-title">Bucket</span>: process.<span class="hljs-property">env</span>.<span class="hljs-property">UPLOAD_BUCKET</span>, <span class="hljs-title">Key</span>: key }),
    { <span class="hljs-attr">expiresIn</span>: <span class="hljs-number">300</span> },
  );
  res.<span class="hljs-title">json</span>({ key, url });
});
</code></pre></div>
<p>A single S3 PUT tops out at 5 GB; beyond that, use S3 multipart upload. Validate the object after upload, not before, and validate any metadata the client sends with a schema, as in <a href="/blog/zod-validation-express-fastify">validating request bodies with Zod</a>.</p>
<h2 id="patch-your-multipart-parser-2026-advisories">Patch your multipart parser: 2026 advisories <a class="post-heading-anchor" href="#patch-your-multipart-parser-2026-advisories" aria-hidden="true">#</a></h2>
<p>Upload parsers have been a steady source of denial-of-service fixes this year. Check yours with <code>npm ls multer @fastify/multipart @fastify/busboy multiparty busboy</code>.</p>
<div class="post-table"><table>
<thead>
<tr>
<th>Package</th>
<th>Upgrade to at least</th>
<th>Fixes (published)</th>
</tr>
</thead>
<tbody>
<tr>
<td>multer</td>
<td>2.4.0</td>
<td>CVE-2026-88932 (Sep 30); CVE-2026-77078, CVE-2026-82333, CVE-2026-77037, CVE-2026-77063 (Aug 31); earlier fixes in 2.1.0 and 2.2.0</td>
</tr>
<tr>
<td>@fastify/multipart</td>
<td>10.1.1</td>
<td><a href="https://osv.dev/vulnerability/CVE-2026-18549" target="_blank" rel="noopener noreferrer">CVE-2026-18549</a>, CVE-2026-19474 (Aug 15)</td>
</tr>
<tr>
<td>@fastify/busboy</td>
<td>3.2.1</td>
<td><a href="https://advisories.gitlab.com/npm/@fastify/busboy/CVE-2026-19484/" target="_blank" rel="noopener noreferrer">CVE-2026-19484</a> (Oct 2)</td>
</tr>
<tr>
<td>multiparty</td>
<td>4.3.1</td>
<td>CVE-2026-87908 (Sep 30)</td>
</tr>
<tr>
<td>body-parser</td>
<td>2.3.0 or 1.20.6</td>
<td>CVE-2026-12590, invalid <code>limit</code> silently ignored (Jul 31)</td>
</tr>
</tbody>
</table></div>
<p>Multer 2.3.0 also added an opt-in <code>limits.fieldArrayIndexLimit</code>, which the CVE-2026-82333 advisory recommends setting.</p>
<h2 id="timeouts-for-long-uploads">Timeouts for long uploads <a class="post-heading-anchor" href="#timeouts-for-long-uploads" aria-hidden="true">#</a></h2>
<p>Node’s HTTP server has had a <code>requestTimeout</code> of 300,000 ms (five minutes) by default since Node 18. A 2 GB upload on a slow connection can exceed that, and your proxy or platform likely has its own limit too. Raise it only on the instance that serves uploads, or use presigned URLs and avoid the question.</p>
<p>For a one-person team, the afternoon version is: upgrade the parser, set explicit limits, and switch from memory to disk storage. A ten-person team should move large uploads to presigned URLs, add a cleanup job for orphaned temp files and alert on disk usage. If memory still climbs afterwards, <a href="/blog/nodejs-memory-leak-heap">heap snapshots</a> will show what is holding it.</p>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<p>Search your code for <code>memoryStorage</code>, <code>toBuffer</code> and <code>Buffer.concat</code> on upload routes, and replace each with a stream to disk or storage.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="how-do-i-upload-large-files-in-node-js-without-running-out-of-memory">How do I upload large files in Node.js without running out of memory? <a class="post-heading-anchor" href="#how-do-i-upload-large-files-in-node-js-without-running-out-of-memory" aria-hidden="true">#</a></h3>
<p>Stream the request body to disk or object storage with <code>stream.pipeline</code> instead of buffering it, and set a <code>fileSize</code> limit. For very large files, have clients upload directly to storage with presigned URLs.</p>
<h3 id="what-is-the-maximum-file-upload-size-in-express">What is the maximum file upload size in Express? <a class="post-heading-anchor" href="#what-is-the-maximum-file-upload-size-in-express" aria-hidden="true">#</a></h3>
<p>Express sets no multipart limit itself. The limit comes from your multipart library’s <code>fileSize</code> option, your proxy and Node’s <code>requestTimeout</code>.</p>
<h3 id="is-multer-safe-to-use-in-2026">Is multer safe to use in 2026? <a class="post-heading-anchor" href="#is-multer-safe-to-use-in-2026" aria-hidden="true">#</a></h3>
<p>Yes, on a patched version. Upgrade to multer 2.4.0 or later, use <code>diskStorage</code> rather than <code>memoryStorage</code> for large files, and set <code>limits</code>.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not instrument upload routes yourself:</p>
<ul>
<li>ApiTraffic records requests <a href="https://www.apitraffic.io/docs/endpoint-handling">under the route pattern</a> with p50, p95 and p99 over time, so a slow or failing upload endpoint shows up separately from the rest of your API. <a href="https://www.apitraffic.io/docs/traffic/search">Search failures</a> with <code>url_path == "/v1/uploads" AND status_code == 413</code>.</li>
<li><a href="https://www.apitraffic.io/docs/outbound-requests">Outbound request logging</a> and APM spans show the time spent sending the file on to storage.</li>
<li>A <a href="https://www.apitraffic.io/docs/waf/policies">WAF rate limit</a> on the upload path, or on an API key header, returns <code>429</code> with <code>Retry-After</code> before abusive clients fill your disk. Monitor mode shows what it would block first.</li>
</ul>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20261005-nodejs-large-file-upload">apitraffic.io</a></p>
<p>Sources: <a href="https://expressjs.com/en/blog/2026-09-30-security-releases/" target="_blank" rel="noopener noreferrer">Express, September 2026 Security Releases (Sep 30, 2026)</a>; <a href="https://expressjs.com/en/blog/2026-08-31-security-releases/" target="_blank" rel="noopener noreferrer">Express, August 2026 Security Releases (Aug 31, 2026)</a>; <a href="https://osv.dev/vulnerability/CVE-2026-18549" target="_blank" rel="noopener noreferrer">OSV, CVE-2026-18549 @fastify/multipart (Aug 15, 2026)</a>; <a href="https://advisories.gitlab.com/npm/@fastify/busboy/CVE-2026-19484/" target="_blank" rel="noopener noreferrer">GitLab Advisory Database, CVE-2026-19484 @fastify/busboy (Oct 2, 2026)</a>; <a href="https://github.com/fastify/fastify-multipart" target="_blank" rel="noopener noreferrer">@fastify/multipart README</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Third-party API latency: measuring vendor slowness and failures in Node</title>
      <link>https://www.apitraffic.io/blog/third-party-api-latency</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/third-party-api-latency</guid>
      <pubDate>Thu, 17 Sep 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>The vendor status page is green and your checkout is slow. Here is how to measure third-party API latency and failures from your own Node servers, per dependency, without wrapping fetch.</description>
      <content:encoded><![CDATA[<p>Checkout is slow, a customer emails, and the vendor’s status page is green. Without your own numbers you cannot prove the payment API is the problem, and you cannot tell the vendor when it started. Measuring third-party API latency from your Node app means recording, for every outbound call, the host, the operation, the time to headers, the total time and the outcome, then looking at <a href="/blog/api-latency-percentiles">p95</a> and error rate per dependency.</p>
<p>In Node, you can collect all of that for every <code>fetch()</code> call without wrapping any code, using <code>diagnostics_channel</code>. This post shows how, what to measure, and what to do with the numbers. If you want one log line per outbound call rather than per-vendor numbers, start with <a href="/blog/log-outbound-http-requests">how to log outbound HTTP requests</a>.</p>
<h2 id="why-your-own-measurements-beat-the-vendors-status-page">Why your own measurements beat the vendor’s status page <a class="post-heading-anchor" href="#why-your-own-measurements-beat-the-vendors-status-page" aria-hidden="true">#</a></h2>
<p>A vendor’s status page reports their aggregate health, usually with a delay. What you need is their health <strong>as seen from your servers</strong>: your region, your network path, your account’s rate limits, your request shapes. A provider can be fine overall and slow for you because of a regional problem or because you are being throttled.</p>
<p>Your own numbers also answer the internal question: how much of your endpoint’s p95 is actually theirs?</p>
<h2 id="what-to-measure-for-each-third-party-api-call">What to measure for each third-party API call <a class="post-heading-anchor" href="#what-to-measure-for-each-third-party-api-call" aria-hidden="true">#</a></h2>
<div class="post-table"><table>
<thead>
<tr>
<th>Measure</th>
<th>Why</th>
</tr>
</thead>
<tbody>
<tr>
<td>Count per dependency and operation</td>
<td>Traffic shape, cost, rate-limit headroom</td>
</tr>
<tr>
<td>Time to headers (TTFB)</td>
<td>The vendor’s processing time plus network</td>
</tr>
<tr>
<td>Total time including body</td>
<td>What your handler actually waited</td>
</tr>
<tr>
<td>Outcome class</td>
<td><code>2xx</code>, <code>4xx</code>, <code>429</code>, <code>5xx</code>, <code>timeout</code>, <code>network</code></td>
</tr>
<tr>
<td>Attempts per logical call</td>
<td>Retries hide failures if you only count successes</td>
</tr>
</tbody>
</table></div>
<p>Group by host and a normalized operation (<code>POST api.vendor.example /v1/charges/:id</code>), not the raw path. Treat <code>429</code> separately from other 4xx: it means you are being throttled, and it trends before an outage-shaped problem does.</p>
<h2 id="how-to-measure-fetch-latency-in-node-js-with-diagnostics-channel">How to measure fetch latency in Node.js with diagnostics_channel <a class="post-heading-anchor" href="#how-to-measure-fetch-latency-in-node-js-with-diagnostics-channel" aria-hidden="true">#</a></h2>
<p>Node’s built-in <code>fetch()</code> is powered by undici, which <a href="https://undici.nodejs.org/api/DiagnosticsChannel" target="_blank" rel="noopener noreferrer">publishes events on <code>diagnostics_channel</code></a>. The same <code>request</code> object appears on every event for a given request, so a <code>WeakMap</code> can carry timings between them:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// outbound-metrics.js</span>
<span class="hljs-keyword">import</span> diagnostics_channel <span class="hljs-keyword">from</span> <span class="hljs-string">'node:diagnostics_channel'</span>;
<span class="hljs-keyword">import</span> client <span class="hljs-keyword">from</span> <span class="hljs-string">'prom-client'</span>;

<span class="hljs-keyword">const</span> duration = <span class="hljs-keyword">new</span> client.<span class="hljs-title">Histogram</span>({
  <span class="hljs-attr">name</span>: <span class="hljs-string">'outbound_request_duration_seconds'</span>,
  <span class="hljs-attr">help</span>: <span class="hljs-string">'Outbound HTTP request duration'</span>,
  <span class="hljs-attr">labelNames</span>: [<span class="hljs-string">'host'</span>, <span class="hljs-string">'op'</span>, <span class="hljs-string">'outcome'</span>],
  <span class="hljs-attr">buckets</span>: [<span class="hljs-number">0.05</span>, <span class="hljs-number">0.1</span>, <span class="hljs-number">0.25</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">1</span>, <span class="hljs-number">2</span>, <span class="hljs-number">5</span>, <span class="hljs-number">10</span>],
});
<span class="hljs-keyword">const</span> ttfb = <span class="hljs-keyword">new</span> client.<span class="hljs-title">Histogram</span>({
  <span class="hljs-attr">name</span>: <span class="hljs-string">'outbound_request_ttfb_seconds'</span>,
  <span class="hljs-attr">help</span>: <span class="hljs-string">'Outbound time to response headers'</span>,
  <span class="hljs-attr">labelNames</span>: [<span class="hljs-string">'host'</span>, <span class="hljs-string">'op'</span>],
  <span class="hljs-attr">buckets</span>: [<span class="hljs-number">0.05</span>, <span class="hljs-number">0.1</span>, <span class="hljs-number">0.25</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">1</span>, <span class="hljs-number">2</span>, <span class="hljs-number">5</span>, <span class="hljs-number">10</span>],
});

<span class="hljs-keyword">const</span> inflight = <span class="hljs-keyword">new</span> <span class="hljs-title">WeakMap</span>();

<span class="hljs-keyword">const</span> <span class="hljs-title">normalize</span> = (<span class="hljs-params">path</span>) =&gt;
  path.<span class="hljs-title">split</span>(<span class="hljs-string">'?'</span>)[<span class="hljs-number">0</span>]
    .<span class="hljs-title">replace</span>(<span class="hljs-regexp">/\/[0-9a-f]{8}-[0-9a-f-]{27,}/gi</span>, <span class="hljs-string">'/:uuid'</span>)
    .<span class="hljs-title">replace</span>(<span class="hljs-regexp">/\/\d+(?=\/|$)/g</span>, <span class="hljs-string">'/:id'</span>);

<span class="hljs-keyword">function</span> <span class="hljs-title">outcomeOf</span>(<span class="hljs-params">status, error</span>) {
  <span class="hljs-keyword">if</span> (error) <span class="hljs-keyword">return</span> error.<span class="hljs-property">name</span> === <span class="hljs-string">'TimeoutError'</span> || <span class="hljs-regexp">/TIMEOUT/</span>.<span class="hljs-title">test</span>(error.<span class="hljs-property">code</span> ?? <span class="hljs-string">''</span>) ? <span class="hljs-string">'timeout'</span> : <span class="hljs-string">'network'</span>;
  <span class="hljs-keyword">if</span> (status === <span class="hljs-number">429</span>) <span class="hljs-keyword">return</span> <span class="hljs-string">'429'</span>;
  <span class="hljs-keyword">return</span> <span class="hljs-string">`<span class="hljs-subst">${<span class="hljs-built_in">Math</span>.floor(status / <span class="hljs-number">100</span>)}</span>xx`</span>;
}

diagnostics_channel.<span class="hljs-title">subscribe</span>(<span class="hljs-string">'undici:request:create'</span>, <span class="hljs-function">(<span class="hljs-params">{ request }</span>) =&gt;</span> {
  inflight.<span class="hljs-title">set</span>(request, {
    <span class="hljs-attr">t0</span>: performance.<span class="hljs-title">now</span>(),
    <span class="hljs-attr">host</span>: <span class="hljs-keyword">new</span> <span class="hljs-title">URL</span>(request.<span class="hljs-property">origin</span>).<span class="hljs-property">host</span>,
    <span class="hljs-attr">op</span>: <span class="hljs-string">`<span class="hljs-subst">${request.method}</span> <span class="hljs-subst">${normalize(request.path)}</span>`</span>,
  });
});

diagnostics_channel.<span class="hljs-title">subscribe</span>(<span class="hljs-string">'undici:request:headers'</span>, <span class="hljs-function">(<span class="hljs-params">{ request, response }</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> s = inflight.<span class="hljs-title">get</span>(request);
  <span class="hljs-keyword">if</span> (!s) <span class="hljs-keyword">return</span>;
  s.<span class="hljs-property">status</span> = response.<span class="hljs-property">statusCode</span>;
  ttfb.<span class="hljs-title">observe</span>({ <span class="hljs-attr">host</span>: s.<span class="hljs-property">host</span>, <span class="hljs-attr">op</span>: s.<span class="hljs-property">op</span> }, (performance.<span class="hljs-title">now</span>() - s.<span class="hljs-property">t0</span>) / <span class="hljs-number">1000</span>);
});

<span class="hljs-keyword">function</span> <span class="hljs-title">finish</span>(<span class="hljs-params">request, error</span>) {
  <span class="hljs-keyword">const</span> s = inflight.<span class="hljs-title">get</span>(request);
  <span class="hljs-keyword">if</span> (!s) <span class="hljs-keyword">return</span>;
  inflight.<span class="hljs-title">delete</span>(request);
  duration.<span class="hljs-title">observe</span>(
    { <span class="hljs-attr">host</span>: s.<span class="hljs-property">host</span>, <span class="hljs-attr">op</span>: s.<span class="hljs-property">op</span>, <span class="hljs-attr">outcome</span>: <span class="hljs-title">outcomeOf</span>(s.<span class="hljs-property">status</span>, error) },
    (performance.<span class="hljs-title">now</span>() - s.<span class="hljs-property">t0</span>) / <span class="hljs-number">1000</span>,
  );
}

diagnostics_channel.<span class="hljs-title">subscribe</span>(<span class="hljs-string">'undici:request:trailers'</span>, <span class="hljs-function">(<span class="hljs-params">{ request }</span>) =&gt;</span> <span class="hljs-title">finish</span>(request));
diagnostics_channel.<span class="hljs-title">subscribe</span>(<span class="hljs-string">'undici:request:error'</span>, <span class="hljs-function">(<span class="hljs-params">{ request, error }</span>) =&gt;</span> <span class="hljs-title">finish</span>(request, error));
</code></pre></div>
<p>Import this file once at startup, before your app makes outbound calls. <code>request:trailers</code> fires when the response body has been fully received, so “total” includes body time; a body you never read never completes, which is itself worth finding.</p>
<p>Keep label cardinality low. If your normalizer misses an ID format, every customer becomes a new series. Check the <code>op</code> values in a staging run first.</p>
<h3 id="what-about-axios-and-node-http-clients">What about axios and node:http clients? <a class="post-heading-anchor" href="#what-about-axios-and-node-http-clients" aria-hidden="true">#</a></h3>
<p>Libraries built on <code>node:http</code> (axios’s Node adapter, got, many SDKs) do not go through undici. Node publishes its own <a href="https://nodejs.org/api/diagnostics_channel.html" target="_blank" rel="noopener noreferrer"><code>http.client.*</code> channels</a> for them, and the same <code>WeakMap</code> pattern works with the <code>request</code> object they carry. The subscriber code for those channels is in <a href="/blog/log-outbound-http-requests">how to log axios and http.request calls</a>.</p>
<h2 id="reading-the-numbers">Reading the numbers <a class="post-heading-anchor" href="#reading-the-numbers" aria-hidden="true">#</a></h2>
<ul>
<li><strong>Dependency p95 versus endpoint p95.</strong> If checkout’s p95 is 1.8 s and the payment call’s p95 is 1.4 s, there is little to gain by optimizing your own code first.</li>
<li><strong>TTFB versus total.</strong> High TTFB is the vendor thinking. A big gap between TTFB and total is large or slow bodies.</li>
<li><strong>Error rate per dependency</strong>, split by class. Rising <code>429</code> means slow down or ask for a higher limit. Rising <code>timeout</code> or <code>network</code> with flat <code>5xx</code> often points at the network path or connection reuse rather than the vendor’s application.</li>
<li><strong>Attempts per logical call.</strong> If retries average 1.3 attempts, the vendor is failing 30% of first tries even if your success rate looks perfect.</li>
</ul>
<h2 id="what-to-do-with-third-party-api-latency-data">What to do with third-party API latency data <a class="post-heading-anchor" href="#what-to-do-with-third-party-api-latency-data" aria-hidden="true">#</a></h2>
<ol>
<li><strong>Set timeouts from it.</strong> A little above the dependency’s p99, inside your endpoint’s budget. The <a href="/blog/nodejs-fetch-timeout">fetch timeout guide</a> has the code.</li>
<li><strong>Add a circuit breaker</strong> for dependencies that fail in bursts, so you fail fast instead of piling up waiting requests. <a href="https://github.com/nodeshift/opossum" target="_blank" rel="noopener noreferrer"><code>opossum</code></a> is a widely used Node library for this.</li>
<li><strong>Cache or move work off the request path.</strong> Exchange rates, feature flags and address lookups rarely need a live call per request. Webhook-driven or queued work removes the vendor from user-facing latency entirely.</li>
<li><strong>Escalate with evidence.</strong> Send the vendor timestamps, the operation, your p95 and error-rate graph, and the request IDs from their response headers. This gets a far better response than “your API feels slow”.</li>
<li><strong>Alert on the symptom, not the vendor.</strong> Per the <a href="/blog/api-alerting-best-practices">alerting guide</a>, page when your users are affected; use per-vendor numbers to diagnose.</li>
</ol>
<p>A solo developer can start with the code above and a weekly look at per-host p95. A team of ten should put dependency panels next to endpoint latency on the main dashboard and review vendors quarterly with these numbers in hand. When you need the request and response bodies too, see <a href="/blog/log-outbound-http-requests">logging outbound HTTP calls</a>.</p>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<ul>
<li>Load <code>outbound-metrics.js</code> in every service.</li>
<li>Chart p95 and error rate per host, with <code>429</code> split out.</li>
<li>Set every outbound timeout from that p99.</li>
<li>Keep a dated list of incidents per vendor for your next renewal conversation.</li>
</ul>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="how-do-i-measure-the-latency-of-fetch-requests-in-node-js">How do I measure the latency of fetch requests in Node.js? <a class="post-heading-anchor" href="#how-do-i-measure-the-latency-of-fetch-requests-in-node-js" aria-hidden="true">#</a></h3>
<p>Subscribe to undici’s <code>diagnostics_channel</code> events (<code>undici:request:create</code>, <code>headers</code>, <code>trailers</code>, <code>error</code>), which Node’s built-in <code>fetch()</code> publishes. Record the time between them per request and export it as a histogram.</p>
<h3 id="does-diagnostics-channel-work-with-axios">Does diagnostics_channel work with axios? <a class="post-heading-anchor" href="#does-diagnostics-channel-work-with-axios" aria-hidden="true">#</a></h3>
<p>Not through the undici channels, because axios uses <code>node:http</code> in Node. Use Node’s experimental <code>http.client.*</code> channels or an axios interceptor instead.</p>
<h3 id="what-is-a-good-timeout-for-a-third-party-api">What is a good timeout for a third-party API? <a class="post-heading-anchor" href="#what-is-a-good-timeout-for-a-third-party-api" aria-hidden="true">#</a></h3>
<p>Slightly above that dependency’s observed p99 from your own servers, and always inside the deadline your endpoint promises its callers.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not build and run this instrumentation yourself, ApiTraffic <a href="https://www.apitraffic.io/docs/outbound-requests">logs outbound requests</a> alongside inbound ones, and its <a href="https://www.apitraffic.io/docs/apm/overview">APM</a> records each outbound <code>fetch</code> as a span attributed to the request that started it.</p>
<ul>
<li>The request waterfall shows each vendor call on the timeline of the user request that triggered it.</li>
<li>An endpoint’s <a href="https://www.apitraffic.io/docs/apm/analytics">Time Breakdown</a> shows how much of its time goes to outbound calls versus queries and your own code.</li>
<li><code>span()</code> adds a custom timing around SDK calls you want to see separately.</li>
<li><a href="https://www.apitraffic.io/docs/span-reports">Span reports</a> rank outbound time by vendor and failed calls by type.</li>
</ul>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260917-third-party-api-latency">apitraffic.io</a></p>
<p>Sources: <a href="https://undici.nodejs.org/api/DiagnosticsChannel" target="_blank" rel="noopener noreferrer">undici Diagnostics Channel docs</a>; <a href="https://nodejs.org/api/diagnostics_channel.html" target="_blank" rel="noopener noreferrer">Node.js docs: diagnostics_channel (built-in HTTP channels)</a>; <a href="https://github.com/siimon/prom-client" target="_blank" rel="noopener noreferrer">prom-client on GitHub</a>; <a href="https://github.com/nodeshift/opossum" target="_blank" rel="noopener noreferrer">opossum on GitHub</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>SSRF in Node.js: the fetch() you didn&apos;t mean to make</title>
      <link>https://www.apitraffic.io/blog/ssrf-in-nodejs</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/ssrf-in-nodejs</guid>
      <pubDate>Tue, 08 Sep 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>A customer pastes a webhook URL pointing at your cloud metadata endpoint, and your API fetches it. Learn how SSRF in Node.js happens, why blocklists on URL strings fail, and a tested safeFetch() you can ship today.</description>
      <content:encoded><![CDATA[<p>You ship a webhook feature: customers paste a URL, your API calls it when an order changes. A week later someone pastes <code>http://169.254.169.254/latest/meta-data/</code> and your server politely fetches your cloud instance’s metadata. That is server-side request forgery, and SSRF in Node.js usually starts with a single <code>fetch(userSuppliedUrl)</code>.</p>
<p>The direct answer: if your API fetches any URL a user can influence (webhooks, link previews, image imports, “import from URL”, OAuth callbacks), allow only <code>http</code>/<code>https</code>, resolve the hostname and refuse private, loopback and link-local addresses at connect time, re-check every redirect, and set a timeout. String checks on the URL are not enough. Below is a tested <code>safeFetch()</code> for Node’s fetch and undici that does all of this.</p>
<h2 id="where-ssrf-hides-in-a-node-api">Where SSRF hides in a Node API <a class="post-heading-anchor" href="#where-ssrf-hides-in-a-node-api" aria-hidden="true">#</a></h2>
<p><a href="https://owasp.org/API-Security/editions/2023/en/0xa7-server-side-request-forgery/" target="_blank" rel="noopener noreferrer">OWASP lists SSRF as API7 in its 2023 API Security Top 10</a>. In a typical <a href="https://www.apitraffic.io/express">Express</a> or <a href="https://www.apitraffic.io/fastify">Fastify</a> API it turns up in:</p>
<ul>
<li><strong>Webhook endpoints</strong> that customers configure.</li>
<li><strong>Link previews and unfurling</strong>, which fetch a page to read its title and image.</li>
<li><strong>“Import from URL”</strong> for avatars, CSVs or documents.</li>
<li><strong>PDF or screenshot rendering</strong> of user-provided HTML or URLs.</li>
<li><strong>HTTP clients with a <code>baseURL</code></strong> where part of the path comes from the user.</li>
</ul>
<p>The last one is subtle. In axios before 1.8.2, an absolute URL passed to an instance with a <code>baseURL</code> was sent to that absolute URL rather than appended to the base (<a href="https://nvd.nist.gov/vuln/detail/CVE-2025-27152" target="_blank" rel="noopener noreferrer">CVE-2025-27152</a>, published March 2025). Version 1.8.2 added the <code>allowAbsoluteUrls</code> option to the HTTP adapter, and it still defaults to <code>true</code>; set it to <code>false</code> on instances that should only ever talk to one host.</p>
<h2 id="why-url-string-checks-fail">Why URL string checks fail <a class="post-heading-anchor" href="#why-url-string-checks-fail" aria-hidden="true">#</a></h2>
<p>Blocking <code>localhost</code> and <code>127.0.0.1</code> in the URL string does not work. The WHATWG URL parser normalizes many spellings of the same address:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">text</span></div><pre><code class="language-text hljs">http://2130706433/          -&gt; hostname 127.0.0.1
http://0x7f.1/              -&gt; hostname 127.0.0.1
http://127.1/               -&gt; hostname 127.0.0.1
http://017700000001/        -&gt; hostname 127.0.0.1
http://[::ffff:127.0.0.1]/  -&gt; hostname [::ffff:7f00:1]
</code></pre></div>
<p>Hostnames are worse. <code>internal.example.com</code> can resolve to <code>10.0.0.5</code>, and with DNS rebinding a name can resolve to a public IP when you check it and a private one when you connect. The <code>ip</code> npm package’s <code>isPublic()</code> was itself bypassable with unusual address formats (<a href="https://nvd.nist.gov/vuln/detail/CVE-2024-29415" target="_blank" rel="noopener noreferrer">CVE-2024-29415</a>, May 2024). The reliable place to check is the IP address the socket actually connects to.</p>
<h2 id="how-to-prevent-ssrf-in-node-js-with-undici">How to prevent SSRF in Node.js with undici <a class="post-heading-anchor" href="#how-to-prevent-ssrf-in-node-js-with-undici" aria-hidden="true">#</a></h2>
<p>Node’s global <code>fetch</code> is built on undici, and <a href="https://undici.nodejs.org/#/docs/api/Agent" target="_blank" rel="noopener noreferrer">undici’s <code>Agent</code> accepts a custom DNS <code>lookup</code></a> for its connections. That hook sees the resolved address right before connecting, which closes the rebinding gap.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// safe-fetch.js</span>
<span class="hljs-keyword">import</span> { <span class="hljs-title">Agent</span>, fetch } <span class="hljs-keyword">from</span> <span class="hljs-string">'undici'</span>;
<span class="hljs-keyword">import</span> { <span class="hljs-title">BlockList</span>, isIP } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:net'</span>;
<span class="hljs-keyword">import</span> dns <span class="hljs-keyword">from</span> <span class="hljs-string">'node:dns'</span>;

<span class="hljs-keyword">const</span> blocked = <span class="hljs-keyword">new</span> <span class="hljs-title">BlockList</span>();
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [net, prefix] <span class="hljs-keyword">of</span> [
  [<span class="hljs-string">'0.0.0.0'</span>, <span class="hljs-number">8</span>], [<span class="hljs-string">'10.0.0.0'</span>, <span class="hljs-number">8</span>], [<span class="hljs-string">'100.64.0.0'</span>, <span class="hljs-number">10</span>], [<span class="hljs-string">'127.0.0.0'</span>, <span class="hljs-number">8</span>],
  [<span class="hljs-string">'169.254.0.0'</span>, <span class="hljs-number">16</span>], [<span class="hljs-string">'172.16.0.0'</span>, <span class="hljs-number">12</span>], [<span class="hljs-string">'192.168.0.0'</span>, <span class="hljs-number">16</span>],
]) blocked.<span class="hljs-title">addSubnet</span>(net, prefix, <span class="hljs-string">'ipv4'</span>);
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [net, prefix] <span class="hljs-keyword">of</span> [[<span class="hljs-string">'::'</span>, <span class="hljs-number">128</span>], [<span class="hljs-string">'::1'</span>, <span class="hljs-number">128</span>], [<span class="hljs-string">'fc00::'</span>, <span class="hljs-number">7</span>], [<span class="hljs-string">'fe80::'</span>, <span class="hljs-number">10</span>]])
  blocked.<span class="hljs-title">addSubnet</span>(net, prefix, <span class="hljs-string">'ipv6'</span>);

<span class="hljs-keyword">function</span> <span class="hljs-title">isBlocked</span>(<span class="hljs-params">address</span>) {
  <span class="hljs-keyword">const</span> v = <span class="hljs-title">isIP</span>(address);
  <span class="hljs-keyword">if</span> (v === <span class="hljs-number">6</span> &amp;&amp; <span class="hljs-regexp">/^::ffff:/i</span>.<span class="hljs-title">test</span>(address) &amp;&amp; <span class="hljs-title">isIP</span>(address.<span class="hljs-title">slice</span>(<span class="hljs-number">7</span>)) === <span class="hljs-number">4</span>) {
    <span class="hljs-keyword">return</span> blocked.<span class="hljs-title">check</span>(address.<span class="hljs-title">slice</span>(<span class="hljs-number">7</span>), <span class="hljs-string">'ipv4'</span>); <span class="hljs-comment">// IPv4-mapped IPv6</span>
  }
  <span class="hljs-keyword">return</span> blocked.<span class="hljs-title">check</span>(address, v === <span class="hljs-number">6</span> ? <span class="hljs-string">'ipv6'</span> : <span class="hljs-string">'ipv4'</span>);
}

<span class="hljs-keyword">function</span> <span class="hljs-title">safeLookup</span>(<span class="hljs-params">hostname, options, callback</span>) {
  dns.<span class="hljs-title">lookup</span>(hostname, { ...options, <span class="hljs-attr">all</span>: <span class="hljs-literal">true</span> }, <span class="hljs-function">(<span class="hljs-params">err, addresses</span>) =&gt;</span> {
    <span class="hljs-keyword">if</span> (err) <span class="hljs-keyword">return</span> <span class="hljs-title">callback</span>(err);
    <span class="hljs-keyword">const</span> bad = addresses.<span class="hljs-title">find</span>(<span class="hljs-function">(<span class="hljs-params">a</span>) =&gt;</span> <span class="hljs-title">isBlocked</span>(a.<span class="hljs-property">address</span>));
    <span class="hljs-keyword">if</span> (bad) <span class="hljs-keyword">return</span> <span class="hljs-title">callback</span>(<span class="hljs-title">Object</span>.<span class="hljs-title">assign</span>(<span class="hljs-keyword">new</span> <span class="hljs-title">Error</span>(<span class="hljs-string">`blocked address <span class="hljs-subst">${bad.address}</span>`</span>), { <span class="hljs-attr">code</span>: <span class="hljs-string">'ESSRF'</span> }));
    <span class="hljs-keyword">if</span> (options.<span class="hljs-property">all</span>) <span class="hljs-keyword">return</span> <span class="hljs-title">callback</span>(<span class="hljs-literal">null</span>, addresses);
    <span class="hljs-title">callback</span>(<span class="hljs-literal">null</span>, addresses[<span class="hljs-number">0</span>].<span class="hljs-property">address</span>, addresses[<span class="hljs-number">0</span>].<span class="hljs-property">family</span>);
  });
}

<span class="hljs-keyword">const</span> agent = <span class="hljs-keyword">new</span> <span class="hljs-title">Agent</span>({ <span class="hljs-attr">connect</span>: { <span class="hljs-attr">lookup</span>: safeLookup } });

<span class="hljs-keyword">function</span> <span class="hljs-title">checkUrl</span>(<span class="hljs-params">raw</span>) {
  <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> <span class="hljs-title">URL</span>(raw);
  <span class="hljs-keyword">if</span> (url.<span class="hljs-property">protocol</span> !== <span class="hljs-string">'https:'</span> &amp;&amp; url.<span class="hljs-property">protocol</span> !== <span class="hljs-string">'http:'</span>) <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title">Error</span>(<span class="hljs-string">'protocol not allowed'</span>);
  <span class="hljs-keyword">if</span> (url.<span class="hljs-property">username</span> || url.<span class="hljs-property">password</span>) <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title">Error</span>(<span class="hljs-string">'credentials in URL not allowed'</span>);
  <span class="hljs-keyword">const</span> host = url.<span class="hljs-property">hostname</span>.<span class="hljs-title">replace</span>(<span class="hljs-regexp">/^\[|\]$/g</span>, <span class="hljs-string">''</span>);
  <span class="hljs-keyword">if</span> (<span class="hljs-title">isIP</span>(host) &amp;&amp; <span class="hljs-title">isBlocked</span>(host)) <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title">Error</span>(<span class="hljs-string">`blocked address <span class="hljs-subst">${host}</span>`</span>); <span class="hljs-comment">// literal IPs skip DNS</span>
  <span class="hljs-keyword">return</span> url;
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title">safeFetch</span>(<span class="hljs-params">raw, { maxRedirects = <span class="hljs-number">3</span>, timeoutMs = <span class="hljs-number">5000</span>, ...init } = {}</span>) {
  <span class="hljs-keyword">let</span> url = <span class="hljs-title">checkUrl</span>(raw);
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> hop = <span class="hljs-number">0</span>; ; hop++) {
    <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title">fetch</span>(url, {
      ...init,
      <span class="hljs-attr">dispatcher</span>: agent,
      <span class="hljs-attr">redirect</span>: <span class="hljs-string">'manual'</span>,
      <span class="hljs-attr">signal</span>: <span class="hljs-title">AbortSignal</span>.<span class="hljs-title">timeout</span>(timeoutMs),
    });
    <span class="hljs-keyword">const</span> location = res.<span class="hljs-property">headers</span>.<span class="hljs-title">get</span>(<span class="hljs-string">'location'</span>);
    <span class="hljs-keyword">if</span> (res.<span class="hljs-property">status</span> &lt; <span class="hljs-number">300</span> || res.<span class="hljs-property">status</span> &gt; <span class="hljs-number">399</span> || !location) <span class="hljs-keyword">return</span> res;
    <span class="hljs-keyword">if</span> (hop &gt;= maxRedirects) <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title">Error</span>(<span class="hljs-string">'too many redirects'</span>);
    <span class="hljs-keyword">await</span> res.<span class="hljs-property">body</span>?.<span class="hljs-title">cancel</span>();
    url = <span class="hljs-title">checkUrl</span>(<span class="hljs-keyword">new</span> <span class="hljs-title">URL</span>(location, url)); <span class="hljs-comment">// re-check every hop</span>
  }
}
</code></pre></div>
<p>Run against a local server, every bypass from the previous section is refused:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">text</span></div><pre><code class="language-text hljs">http://127.0.0.1:3021/            BLOCKED blocked address 127.0.0.1
http://2130706433:3021/           BLOCKED blocked address 127.0.0.1
http://[::ffff:127.0.0.1]:3021/   BLOCKED blocked address ::ffff:7f00:1
http://localhost:3021/            BLOCKED blocked address 127.0.0.1
file:///etc/passwd                BLOCKED protocol not allowed
http://169.254.169.254/latest/... BLOCKED blocked address 169.254.169.254
</code></pre></div>
<h3 id="why-each-piece-is-there">Why each piece is there <a class="post-heading-anchor" href="#why-each-piece-is-there" aria-hidden="true">#</a></h3>
<ol>
<li><strong>The literal IP check</strong> exists because a URL with an IP address never triggers a DNS lookup, so <code>safeLookup</code> alone would let <code>http://127.0.0.1/</code> through.</li>
<li><strong><code>redirect: 'manual'</code></strong> stops undici from following a <code>302</code> to <code>http://169.254.169.254/</code> on its own. In Node, a manual redirect returns the real <code>3xx</code> response with its <code>Location</code> header.</li>
<li><strong>The timeout</strong> stops a slow or hanging target from tying up your server; see <a href="/blog/nodejs-fetch-timeout">fetch timeouts in Node.js</a> for the details of <code>AbortSignal.timeout()</code>.</li>
<li><strong>Import <code>fetch</code> and <code>Agent</code> from the same undici package.</strong> Passing an npm undici <code>Agent</code> to the global <code>fetch</code> can work, but the bundled and npm versions can drift apart.</li>
</ol>
<p>Extend the blocklist for your network: add your VPC ranges, and any IPv6 ranges you route internally.</p>
<h2 id="ssrf-in-node-js-on-cloud-platforms">SSRF in Node.js on cloud platforms <a class="post-heading-anchor" href="#ssrf-in-node-js-on-cloud-platforms" aria-hidden="true">#</a></h2>
<p>The prize in most SSRF attacks is cloud credentials. On AWS EC2 the instance metadata service lives at <code>169.254.169.254</code>; on ECS, task credentials come from <code>169.254.170.2</code>. Both fall inside <code>169.254.0.0/16</code>, which the code above blocks. Two more layers are worth an afternoon:</p>
<ul>
<li>On EC2, require IMDSv2, which needs a <code>PUT</code> request to get a session token first and blocks the simple <code>GET</code>-based SSRF.</li>
<li>Run URL-fetching work (webhooks, previews) in a separate worker or service with no cloud credentials and outbound-only network access.</li>
</ul>
<p>On PaaS hosts like Render, Railway, <a href="http://Fly.io" target="_blank" rel="noopener noreferrer">Fly.io</a> or Heroku the metadata risk differs, but internal services on the private network are still reachable. The same rules apply.</p>
<h2 id="ssrf-prevention-checklist">SSRF prevention checklist <a class="post-heading-anchor" href="#ssrf-prevention-checklist" aria-hidden="true">#</a></h2>
<ul>
<li>[ ] Every user-influenced URL goes through one <code>safeFetch()</code>; no raw <code>fetch(req.body.url)</code>.</li>
<li>[ ] Only <code>http:</code> and <code>https:</code>; no credentials in URLs.</li>
<li>[ ] Resolved IPs checked at connect time, including literal IPs and IPv4-mapped IPv6.</li>
<li>[ ] Redirects followed manually and re-checked, with a hop limit.</li>
<li>[ ] Timeouts and a maximum response size.</li>
<li>[ ] axios instances with a <code>baseURL</code> set <code>allowAbsoluteUrls: false</code>.</li>
<li>[ ] IMDSv2 required on EC2.</li>
<li>[ ] Outbound requests logged with the destination host.</li>
</ul>
<p>A solo developer can grep for <code>fetch(</code>, <code>axios</code> and <code>got(</code> calls that take request data, route them through <code>safeFetch()</code>, and be done in an afternoon. A team of ten should also add a lint rule or code-review check that bans fetching user-provided URLs any other way, and move webhook delivery to a worker with its own network policy.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="what-is-ssrf-in-node-js">What is SSRF in Node.js? <a class="post-heading-anchor" href="#what-is-ssrf-in-node-js" aria-hidden="true">#</a></h3>
<p>Server-side request forgery is when an attacker makes your server send a request to a destination they choose, usually an internal service or cloud metadata endpoint. In Node.js it usually comes from passing user input to <code>fetch</code>, axios or another HTTP client.</p>
<h3 id="does-nodes-fetch-protect-against-ssrf">Does Node’s fetch protect against SSRF? <a class="post-heading-anchor" href="#does-nodes-fetch-protect-against-ssrf" aria-hidden="true">#</a></h3>
<p>No. Node’s built-in <code>fetch</code> follows redirects and connects to any address, including <code>127.0.0.1</code> and <code>169.254.169.254</code>. You need to add the checks yourself, as above, or use a library that does.</p>
<h3 id="is-blocking-private-ip-ranges-enough-to-stop-ssrf">Is blocking private IP ranges enough to stop SSRF? <a class="post-heading-anchor" href="#is-blocking-private-ip-ranges-enough-to-stop-ssrf" aria-hidden="true">#</a></h3>
<p>It stops the most damaging cases, if you check the resolved IP at connect time and re-check redirects. For high-risk features, also isolate URL fetching in a service with no credentials and restricted network access.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not stitch together outbound visibility yourself, ApiTraffic <a href="https://www.apitraffic.io/docs/outbound-requests">logs outbound requests alongside inbound ones</a>, and APM attributes each outbound <code>fetch</code> span to the request that started it. A webhook or preview call to an unexpected host shows up on the request’s waterfall, tied to the user input that caused it (more on <a href="/blog/log-outbound-http-requests">logging outbound HTTP requests</a>).</p>
<p>On the inbound side, <a href="https://www.apitraffic.io/docs/waf/policies">firewall access lists</a> can deny by IP, CIDR, country, ASN or path, with monitor mode to check a rule before it blocks.</p>
<p>Start free and see every outbound call your API makes: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260908-ssrf-in-nodejs">apitraffic.io</a></p>
<p>Sources: <a href="https://owasp.org/API-Security/editions/2023/en/0xa7-server-side-request-forgery/" target="_blank" rel="noopener noreferrer">OWASP API Security Top 10 2023: API7 Server Side Request Forgery</a>; <a href="https://undici.nodejs.org/#/docs/api/Agent" target="_blank" rel="noopener noreferrer">undici Agent and connect options</a>; <a href="https://nodejs.org/api/net.html#class-netblocklist" target="_blank" rel="noopener noreferrer">Node.js net.BlockList</a>; <a href="https://nvd.nist.gov/vuln/detail/CVE-2025-27152" target="_blank" rel="noopener noreferrer">NVD: CVE-2025-27152 (axios), March 2025</a>; <a href="https://nvd.nist.gov/vuln/detail/CVE-2024-29415" target="_blank" rel="noopener noreferrer">NVD: CVE-2024-29415 (ip), May 2024</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Cursor vs offset pagination: an API design that survives big tables and scrapers</title>
      <link>https://www.apitraffic.io/blog/cursor-vs-offset-pagination</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/cursor-vs-offset-pagination</guid>
      <pubDate>Fri, 21 Aug 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>Offset pagination was fast at launch; now deep pages hammer the database and one API key is walking every page. Here is when to switch to cursors and how.</description>
      <content:encoded><![CDATA[<p>Your <code>/v1/orders?page=1</code> endpoint was fast at launch. Two years later, <code>page=4000</code> takes nine seconds, the database CPU graph has a new plateau, and the request log shows one API key walking every page in order at 3am.</p>
<p>The short answer on cursor vs offset pagination: offset pagination (<code>LIMIT 50 OFFSET 200000</code>) gets slower the deeper you go because the database still reads every skipped row, and it returns duplicates or skips when rows are inserted mid-scroll. Cursor pagination (also called keyset pagination) asks for “the next 50 after this row”, stays fast at any depth with the right index, and is stable under inserts. Use cursors for anything large, public or growing, and keep offset for small admin tables where jumping to page 7 matters.</p>
<h2 id="cursor-vs-offset-pagination-compared">Cursor vs offset pagination compared <a class="post-heading-anchor" href="#cursor-vs-offset-pagination-compared" aria-hidden="true">#</a></h2>
<div class="post-table"><table>
<thead>
<tr>
<th></th>
<th>Offset pagination</th>
<th>Cursor (keyset) pagination</th>
</tr>
</thead>
<tbody>
<tr>
<td>Request</td>
<td><code>?page=7&amp;limit=50</code></td>
<td><code>?after=eyJ0Ijo...&amp;limit=50</code></td>
</tr>
<tr>
<td>Cost at depth</td>
<td>Grows with offset</td>
<td>Constant with an index</td>
</tr>
<tr>
<td>Inserts during paging</td>
<td>Duplicates or skipped rows</td>
<td>Stable</td>
</tr>
<tr>
<td>Jump to page N</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>Total count</td>
<td>Often expected, often expensive</td>
<td>Usually omitted</td>
</tr>
<tr>
<td>Scraper friendliness</td>
<td>Trivial to parallelize</td>
<td>Must follow the chain</td>
</tr>
</tbody>
</table></div>
<h2 id="why-offset-pagination-gets-slow-on-large-tables">Why offset pagination gets slow on large tables <a class="post-heading-anchor" href="#why-offset-pagination-gets-slow-on-large-tables" aria-hidden="true">#</a></h2>
<p><a href="https://www.postgresql.org/docs/current/queries-limit.html" target="_blank" rel="noopener noreferrer">PostgreSQL’s documentation</a> says it plainly: rows skipped by an <code>OFFSET</code> clause still have to be computed inside the server, so a large offset can be inefficient. <code>OFFSET 200000</code> means reading and discarding 200,000 rows to return 50. MySQL behaves the same way.</p>
<p>There is also a correctness problem. If a new order is inserted at the top while a client is on page 3, every row shifts down by one, and page 4 repeats the last row of page 3. Deletes cause skipped rows. For a feed, an export or a sync job, that is a bug.</p>
<p>Finally, offset endpoints often return a total count, and <code>SELECT count(*)</code> on a big filtered table is its own slow query on every request.</p>
<h2 id="how-to-implement-cursor-pagination-in-node-js">How to implement cursor pagination in Node.js <a class="post-heading-anchor" href="#how-to-implement-cursor-pagination-in-node-js" aria-hidden="true">#</a></h2>
<h3 id="step-1-pick-a-stable-unique-sort-order">Step 1: pick a stable, unique sort order <a class="post-heading-anchor" href="#step-1-pick-a-stable-unique-sort-order" aria-hidden="true">#</a></h3>
<p>Cursors need a sort key that never ties. <code>created_at</code> alone can tie, so pair it with the primary key: <code>ORDER BY created_at DESC, id DESC</code>.</p>
<h3 id="step-2-add-a-matching-index">Step 2: add a matching index <a class="post-heading-anchor" href="#step-2-add-a-matching-index" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">sql</span></div><pre><code class="language-sql hljs"><span class="hljs-keyword">CREATE</span> INDEX CONCURRENTLY orders_account_created_id
  <span class="hljs-keyword">ON</span> orders (account_id, created_at <span class="hljs-keyword">DESC</span>, id <span class="hljs-keyword">DESC</span>);
</code></pre></div>
<h3 id="step-3-encode-the-cursor-and-query-with-a-row-comparison">Step 3: encode the cursor and query with a row comparison <a class="post-heading-anchor" href="#step-3-encode-the-cursor-and-query-with-a-row-comparison" aria-hidden="true">#</a></h3>
<p>This uses <a href="https://www.apitraffic.io/fastify">Fastify</a> v5 and <code>pg</code>. The cursor is opaque to clients: base64url-encoded JSON of the last row’s sort keys.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> <span class="hljs-title">Fastify</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'fastify'</span>;
<span class="hljs-keyword">import</span> pg <span class="hljs-keyword">from</span> <span class="hljs-string">'pg'</span>;

<span class="hljs-keyword">const</span> app = <span class="hljs-title">Fastify</span>({ <span class="hljs-attr">logger</span>: <span class="hljs-literal">true</span> });
<span class="hljs-keyword">const</span> pool = <span class="hljs-keyword">new</span> pg.<span class="hljs-title">Pool</span>({ <span class="hljs-attr">connectionString</span>: process.<span class="hljs-property">env</span>.<span class="hljs-property">DATABASE_URL</span> });

<span class="hljs-keyword">const</span> <span class="hljs-title">encode</span> = (<span class="hljs-params">row</span>) =&gt;
  <span class="hljs-title">Buffer</span>.<span class="hljs-title">from</span>(<span class="hljs-title">JSON</span>.<span class="hljs-title">stringify</span>({ <span class="hljs-attr">t</span>: row.<span class="hljs-property">cursor_ts</span>, <span class="hljs-attr">id</span>: row.<span class="hljs-property">id</span> })).<span class="hljs-title">toString</span>(<span class="hljs-string">'base64url'</span>);

<span class="hljs-keyword">function</span> <span class="hljs-title">decode</span>(<span class="hljs-params">cursor</span>) {
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> { t, id } = <span class="hljs-title">JSON</span>.<span class="hljs-title">parse</span>(<span class="hljs-title">Buffer</span>.<span class="hljs-title">from</span>(cursor, <span class="hljs-string">'base64url'</span>).<span class="hljs-title">toString</span>());
    <span class="hljs-keyword">if</span> (<span class="hljs-keyword">typeof</span> id !== <span class="hljs-string">'string'</span> || <span class="hljs-title">Number</span>.<span class="hljs-built_in">isNaN</span>(<span class="hljs-title">Date</span>.<span class="hljs-title">parse</span>(t))) <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
    <span class="hljs-keyword">return</span> { t, id };
  } <span class="hljs-keyword">catch</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
  }
}

app.<span class="hljs-title">get</span>(<span class="hljs-string">'/v1/orders'</span>, <span class="hljs-title">async</span> (request, reply) =&gt; {
  <span class="hljs-keyword">const</span> limit = <span class="hljs-title">Math</span>.<span class="hljs-title">min</span>(<span class="hljs-title">Number</span>(request.<span class="hljs-property">query</span>.<span class="hljs-property">limit</span>) || <span class="hljs-number">50</span>, <span class="hljs-number">100</span>);
  <span class="hljs-keyword">const</span> after = request.<span class="hljs-property">query</span>.<span class="hljs-property">after</span> ? <span class="hljs-title">decode</span>(request.<span class="hljs-property">query</span>.<span class="hljs-property">after</span>) : <span class="hljs-literal">null</span>;
  <span class="hljs-keyword">if</span> (request.<span class="hljs-property">query</span>.<span class="hljs-property">after</span> &amp;&amp; !after) {
    <span class="hljs-keyword">return</span> reply.<span class="hljs-title">code</span>(<span class="hljs-number">400</span>).<span class="hljs-title">send</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'invalid_cursor'</span> });
  }

  <span class="hljs-keyword">const</span> params = [request.<span class="hljs-property">user</span>.<span class="hljs-property">accountId</span>, limit + <span class="hljs-number">1</span>];
  <span class="hljs-keyword">let</span> where = <span class="hljs-string">'account_id = $1'</span>;
  <span class="hljs-keyword">if</span> (after) {
    params.<span class="hljs-title">push</span>(after.<span class="hljs-property">t</span>, after.<span class="hljs-property">id</span>);
    where += <span class="hljs-string">' AND (created_at, id) &lt; ($3::timestamptz, $4)'</span>;
  }

  <span class="hljs-keyword">const</span> { rows } = <span class="hljs-keyword">await</span> pool.<span class="hljs-title">query</span>(
    <span class="hljs-string">`SELECT id, created_at, created_at::text AS cursor_ts, total_cents, status
       FROM orders
      WHERE <span class="hljs-subst">${where}</span>
      ORDER BY created_at DESC, id DESC
      LIMIT $2`</span>,
    params,
  );

  <span class="hljs-keyword">const</span> hasMore = rows.<span class="hljs-property">length</span> &gt; limit;
  <span class="hljs-keyword">const</span> page = hasMore ? rows.<span class="hljs-title">slice</span>(<span class="hljs-number">0</span>, limit) : rows;
  <span class="hljs-keyword">return</span> {
    <span class="hljs-attr">data</span>: page.<span class="hljs-title">map</span>(<span class="hljs-function">(<span class="hljs-params">{ cursor_ts, ...order }</span>) =&gt;</span> order),
    <span class="hljs-attr">next_cursor</span>: hasMore ? <span class="hljs-title">encode</span>(page.<span class="hljs-title">at</span>(-<span class="hljs-number">1</span>)) : <span class="hljs-literal">null</span>,
  };
});

<span class="hljs-keyword">await</span> app.<span class="hljs-title">listen</span>({ <span class="hljs-attr">port</span>: <span class="hljs-number">3000</span> });
</code></pre></div>
<p>The <code>(created_at, id) &lt; (...)</code> <a href="https://www.postgresql.org/docs/current/functions-comparisons.html" target="_blank" rel="noopener noreferrer">row comparison is PostgreSQL syntax</a> that matches the composite index, so the database seeks straight to the right spot. Fetching <code>limit + 1</code> rows tells you whether there is a next page without a count query. The <code>account_id</code> condition matters as much as the cursor: a cursor must never let a caller page into another tenant’s rows, the same lesson as <a href="/blog/bola-vulnerability-node-api">BOLA and IDOR</a>.</p>
<p>The cursor uses <code>created_at::text</code> rather than the value <code>pg</code> returns, because <code>pg</code> converts timestamps to JavaScript <code>Date</code> objects, which keep only milliseconds. PostgreSQL stores microseconds, so a cursor built from a <code>Date</code> can skip rows.</p>
<h3 id="step-4-return-the-cursor-in-a-predictable-place">Step 4: return the cursor in a predictable place <a class="post-heading-anchor" href="#step-4-return-the-cursor-in-a-predictable-place" aria-hidden="true">#</a></h3>
<p>Put <code>next_cursor</code> in the body, or use a <code>Link</code> header with <code>rel="next"</code> as described in <a href="https://www.rfc-editor.org/rfc/rfc8288" target="_blank" rel="noopener noreferrer">RFC 8288</a>. Clients loop until <code>next_cursor</code> is <code>null</code>.</p>
<h2 id="when-offset-pagination-is-still-fine">When offset pagination is still fine <a class="post-heading-anchor" href="#when-offset-pagination-is-still-fine" aria-hidden="true">#</a></h2>
<ul>
<li>Admin tables with a few thousand rows and a page picker.</li>
<li>Search results where nobody goes past page 10. Cap the offset so they cannot.</li>
<li>Reports where the dataset is fixed for the duration of the read.</li>
</ul>
<p>If you keep offset, cap it: <code>if (offset &gt; 10_000) return reply.code(400)</code>.</p>
<h2 id="how-pagination-design-affects-scrapers">How pagination design affects scrapers <a class="post-heading-anchor" href="#how-pagination-design-affects-scrapers" aria-hidden="true">#</a></h2>
<p>Offset endpoints are a gift to scrapers. Pages are independent, so a script can fetch pages 1 through 5,000 in parallel, and the deep ones are your most expensive queries. Cursor endpoints force a scraper to walk the chain one request at a time, which turns a parallel burst into a sequential crawl you can rate limit.</p>
<p>Protections that work with either design:</p>
<ul>
<li>Cap <code>limit</code> server-side. A client asking for <code>limit=100000</code> should get 100.</li>
<li>Rate limit list endpoints per API key, not only per IP. <a href="/blog/nodejs-rate-limiting">Rate limiting algorithms compared</a> covers the options.</li>
<li>Watch for one key walking every page of a list endpoint, or requesting deep offsets in parallel.</li>
<li>Keep expensive filters behind indexes. List endpoints are often where <a href="/blog/n-plus-1-queries-node">N+1 queries</a> hide as well.</li>
</ul>
<p>For a one-person team, the afternoon version is adding cursor pagination to the one or two list endpoints that are actually large, and capping <code>limit</code> and <code>offset</code> everywhere else. A ten-person team should make cursor pagination the default in a shared helper, document the cursor contract, and add a deprecation path for offset parameters on public endpoints.</p>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<p>Cap <code>limit</code> and <code>offset</code> on every list endpoint today. It costs ten minutes and removes the worst-case query from your API.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="is-cursor-pagination-faster-than-offset-pagination">Is cursor pagination faster than offset pagination? <a class="post-heading-anchor" href="#is-cursor-pagination-faster-than-offset-pagination" aria-hidden="true">#</a></h3>
<p>At depth, yes. With an index matching the sort order, a cursor query reads only the rows it returns, while <code>OFFSET</code> reads and discards every skipped row. On the first page they perform about the same.</p>
<h3 id="how-do-i-jump-to-a-specific-page-with-cursor-pagination">How do I jump to a specific page with cursor pagination? <a class="post-heading-anchor" href="#how-do-i-jump-to-a-specific-page-with-cursor-pagination" aria-hidden="true">#</a></h3>
<p>You cannot jump directly. If users need that, offer filters such as a date range that narrow the set, or keep offset pagination for small datasets only.</p>
<h3 id="should-pagination-cursors-be-encrypted-or-signed">Should pagination cursors be encrypted or signed? <a class="post-heading-anchor" href="#should-pagination-cursors-be-encrypted-or-signed" aria-hidden="true">#</a></h3>
<p>They should be opaque so clients do not build them by hand. Encoding is enough if the server validates the decoded values and always applies the caller’s tenant filter. Sign them with an HMAC if you do not want clients editing them.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not build scraper detection and per-key limits yourself:</p>
<ul>
<li>ApiTraffic records every request <a href="https://www.apitraffic.io/docs/endpoint-handling">under its route pattern</a> with p50, p95 and p99 per endpoint, so a list endpoint slowing as tables grow shows up before customers report it. The Time Breakdown and request waterfall show whether that time is the query.</li>
<li><a href="https://www.apitraffic.io/docs/apm/analytics">Top Operations</a> ranks queries by calls per request and flags N+1 patterns with the code location that issued them.</li>
<li>A <a href="https://www.apitraffic.io/docs/waf/policies">WAF rate limit</a> keyed on a header such as your API key returns <code>429</code> with <code>Retry-After</code>, and behavioral protection scores callers on distinct object IDs per endpoint and distinct endpoints. Monitor mode shows what would be blocked first.</li>
</ul>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260821-cursor-vs-offset-pagination">apitraffic.io</a></p>
<p>Sources: <a href="https://www.postgresql.org/docs/current/queries-limit.html" target="_blank" rel="noopener noreferrer">PostgreSQL documentation, LIMIT and OFFSET</a>; <a href="https://www.postgresql.org/docs/current/functions-comparisons.html" target="_blank" rel="noopener noreferrer">PostgreSQL documentation, row constructor comparison</a>; <a href="https://www.rfc-editor.org/rfc/rfc8288" target="_blank" rel="noopener noreferrer">RFC 8288, Web Linking</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>API alerting best practices: what to page on for a Node API</title>
      <link>https://www.apitraffic.io/blog/api-alerting-best-practices</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/api-alerting-best-practices</guid>
      <pubDate>Tue, 04 Aug 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>A 3am CPU page that meant nothing, then a real outage that paged nobody. Here is the short list of alerts a small Node API team needs, with rules and code to match.</description>
      <content:encoded><![CDATA[<p>Your phone buzzes at 3am: CPU over 80% on one instance. You look, nothing is wrong for users, and you go back to sleep a little less willing to trust the next alert. A week later a real outage pages nobody, because the alert that mattered did not exist. API alerting best practices come down to one rule: page on symptoms users feel (errors, latency, the API not answering), send everything else to a ticket or a dashboard, and require every page to be actionable.</p>
<p>For a Node API run by a team of 1 to 20, that means four or five alerts, not forty. Here is which ones, how to keep them quiet, and working instrumentation and alert rules.</p>
<h2 id="what-should-you-alert-on-for-an-api">What should you alert on for an API? <a class="post-heading-anchor" href="#what-should-you-alert-on-for-an-api" aria-hidden="true">#</a></h2>
<p><a href="https://sre.google/sre-book/monitoring-distributed-systems/" target="_blank" rel="noopener noreferrer">The Google SRE book’s guidance</a> is to alert on symptoms rather than causes. For an HTTP API the symptom list is short:</p>
<div class="post-table"><table>
<thead>
<tr>
<th>Alert</th>
<th>Condition (example)</th>
<th>Page or ticket</th>
</tr>
</thead>
<tbody>
<tr>
<td>Error rate</td>
<td>5xx above 2% of requests for 5 minutes, at least 50 requests</td>
<td>Page</td>
</tr>
<tr>
<td>Latency</td>
<td>p95 of key endpoints above 2x normal for 10 minutes</td>
<td>Page</td>
</tr>
<tr>
<td>Down</td>
<td>External check fails from 2+ locations for 3 minutes</td>
<td>Page</td>
</tr>
<tr>
<td>Running out</td>
<td>Disk, connection limit or quota will run out within hours</td>
<td>Page if hours, ticket if days</td>
</tr>
<tr>
<td>TLS certificate</td>
<td>Expires in under 14 days</td>
<td>Ticket</td>
</tr>
</tbody>
</table></div>
<p>Replace the example numbers with values from your own baseline. The structure matters more than the thresholds: ratios instead of counts, a minimum volume, and a duration.</p>
<h2 id="api-alerting-best-practices-what-not-to-page-on">API alerting best practices: what not to page on <a class="post-heading-anchor" href="#api-alerting-best-practices-what-not-to-page-on" aria-hidden="true">#</a></h2>
<ul>
<li><strong>CPU or memory percentage.</strong> High CPU with normal latency is a cost question, not an emergency.</li>
<li><strong>A single 500.</strong> One error is a log line. Many errors are a rate.</li>
<li><strong>Raw 4xx counts.</strong> Clients send bad requests; a 4xx spike is often a scanner or a broken client release. Ticket it, or route it to a security review.</li>
<li><strong>One process restart.</strong> Page on restarts only if they cause errors users see, which the error-rate alert already catches.</li>
<li><strong>A dependency’s latency alone.</strong> If the vendor’s slowness hurts your users, your own latency alert fires.</li>
</ul>
<p>Causes like these belong on a dashboard you open when a symptom alert fires. <a href="/blog/nodejs-event-loop-lag">Event loop lag</a> is a good example: a vital diagnostic, a poor page.</p>
<h2 id="how-to-reduce-alert-noise">How to reduce alert noise <a class="post-heading-anchor" href="#how-to-reduce-alert-noise" aria-hidden="true">#</a></h2>
<h3 id="use-rates-and-minimum-volumes">Use rates and minimum volumes <a class="post-heading-anchor" href="#use-rates-and-minimum-volumes" aria-hidden="true">#</a></h3>
<p>Five errors out of ten requests at 4am is 50%, and it may be one confused client. Require a minimum request count before an error-rate alert can fire.</p>
<h3 id="add-a-duration">Add a duration <a class="post-heading-anchor" href="#add-a-duration" aria-hidden="true">#</a></h3>
<p>Use a <code>for</code> window (5 to 10 minutes) so a spike during a deploy does not page. Short, real outages are still caught by the external “down” check.</p>
<h3 id="alert-on-the-routes-that-matter">Alert on the routes that matter <a class="post-heading-anchor" href="#alert-on-the-routes-that-matter" aria-hidden="true">#</a></h3>
<p>Exclude <code>/health</code>, webhooks you ignore and admin routes from latency alerts, or alert per group of routes. One slow export endpoint should not page as “API slow”.</p>
<h3 id="consider-burn-rate-once-you-have-an-slo">Consider burn rate once you have an SLO <a class="post-heading-anchor" href="#consider-burn-rate-once-you-have-an-slo" aria-hidden="true">#</a></h3>
<p>If you define an availability target, alerting on how fast you burn the error budget (fast burn pages, slow burn tickets) replaces fixed thresholds well. The <a href="/blog/error-budget-small-api-team">SLOs and error budgets</a> post covers how to set one up without much ceremony.</p>
<h2 id="how-to-instrument-a-node-api-for-alerting">How to instrument a Node API for alerting <a class="post-heading-anchor" href="#how-to-instrument-a-node-api-for-alerting" aria-hidden="true">#</a></h2>
<p>A request histogram labeled by route and status class is enough for every alert above. With <a href="https://github.com/siimon/prom-client" target="_blank" rel="noopener noreferrer"><code>prom-client</code></a> and <a href="https://www.apitraffic.io/express">Express</a>:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> client <span class="hljs-keyword">from</span> <span class="hljs-string">'prom-client'</span>;

<span class="hljs-keyword">const</span> httpDuration = <span class="hljs-keyword">new</span> client.<span class="hljs-title">Histogram</span>({
  <span class="hljs-attr">name</span>: <span class="hljs-string">'http_request_duration_seconds'</span>,
  <span class="hljs-attr">help</span>: <span class="hljs-string">'HTTP request duration'</span>,
  <span class="hljs-attr">labelNames</span>: [<span class="hljs-string">'method'</span>, <span class="hljs-string">'route'</span>, <span class="hljs-string">'status_class'</span>],
  <span class="hljs-attr">buckets</span>: [<span class="hljs-number">0.025</span>, <span class="hljs-number">0.05</span>, <span class="hljs-number">0.1</span>, <span class="hljs-number">0.25</span>, <span class="hljs-number">0.5</span>, <span class="hljs-number">1</span>, <span class="hljs-number">2.5</span>, <span class="hljs-number">5</span>],
});

<span class="hljs-keyword">const</span> app = <span class="hljs-title">express</span>();

app.<span class="hljs-title">use</span>(<span class="hljs-function">(<span class="hljs-params">req, res, next</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> end = httpDuration.<span class="hljs-title">startTimer</span>();
  res.<span class="hljs-title">on</span>(<span class="hljs-string">'finish'</span>, <span class="hljs-function">() =&gt;</span> {
    <span class="hljs-title">end</span>({
      <span class="hljs-attr">method</span>: req.<span class="hljs-property">method</span>,
      <span class="hljs-attr">route</span>: req.<span class="hljs-property">route</span>?.<span class="hljs-property">path</span> ?? <span class="hljs-string">'unmatched'</span>, <span class="hljs-comment">// the pattern, not the raw URL</span>
      <span class="hljs-attr">status_class</span>: <span class="hljs-string">`<span class="hljs-subst">${<span class="hljs-built_in">Math</span>.floor(res.statusCode / <span class="hljs-number">100</span>)}</span>xx`</span>,
    });
  });
  <span class="hljs-title">next</span>();
});

app.<span class="hljs-title">get</span>(<span class="hljs-string">'/metrics'</span>, <span class="hljs-title">async</span> (req, res) =&gt; {
  res.<span class="hljs-title">set</span>(<span class="hljs-string">'content-type'</span>, client.<span class="hljs-property">register</span>.<span class="hljs-property">contentType</span>);
  res.<span class="hljs-title">send</span>(<span class="hljs-keyword">await</span> client.<span class="hljs-property">register</span>.<span class="hljs-title">metrics</span>());
});
</code></pre></div>
<p>Label by route pattern, never by raw URL, or every user ID becomes a new time series. Note that <code>req.route</code> holds only the last matched path segment in nested routers; prefix it with <code>req.baseUrl</code> if you mount routers.</p>
<p>Then the alert rules, in <a href="https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/" target="_blank" rel="noopener noreferrer">Prometheus format</a>:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">yaml</span></div><pre><code class="language-yaml hljs"><span class="hljs-attr">groups:</span>
  <span class="hljs-bullet">-</span> <span class="hljs-attr">name:</span> <span class="hljs-string">api</span>
    <span class="hljs-attr">rules:</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">alert:</span> <span class="hljs-string">ApiHighErrorRate</span>
        <span class="hljs-attr">expr:</span> <span class="hljs-string">|
          sum(rate(http_request_duration_seconds_count{status_class="5xx"}[5m]))
            / sum(rate(http_request_duration_seconds_count[5m])) &gt; 0.02
          and sum(increase(http_request_duration_seconds_count[5m])) &gt; 50
</span>        <span class="hljs-attr">for:</span> <span class="hljs-string">5m</span>
        <span class="hljs-attr">labels:</span> { <span class="hljs-attr">severity:</span> <span class="hljs-string">page</span> }
        <span class="hljs-attr">annotations:</span>
          <span class="hljs-attr">summary:</span> <span class="hljs-string">"5xx rate above 2% for 5m"</span>
          <span class="hljs-attr">runbook:</span> <span class="hljs-string">"https://wiki.example.com/runbooks/api-errors"</span>
      <span class="hljs-bullet">-</span> <span class="hljs-attr">alert:</span> <span class="hljs-string">ApiSlowCheckout</span>
        <span class="hljs-attr">expr:</span> <span class="hljs-string">|
          histogram_quantile(0.95, sum by (le) (
            rate(http_request_duration_seconds_bucket{route=~"/checkout.*"}[10m]))) &gt; 1
</span>        <span class="hljs-attr">for:</span> <span class="hljs-string">10m</span>
        <span class="hljs-attr">labels:</span> { <span class="hljs-attr">severity:</span> <span class="hljs-string">page</span> }
</code></pre></div>
<p>The same logic works in Grafana Cloud, Datadog, New Relic or your platform’s built-in alerting; the names change, the structure does not. For the “down” alert, use an external uptime check against a real endpoint, not just <code>/health</code> (see <a href="/blog/nodejs-health-check">health checks that tell the truth</a>).</p>
<h2 id="who-gets-paged-on-a-small-team">Who gets paged on a small team? <a class="post-heading-anchor" href="#who-gets-paged-on-a-small-team" aria-hidden="true">#</a></h2>
<ul>
<li><strong>Solo developer:</strong> pages go to your phone, so be strict. Three alerts: error rate, latency on your most important route, and down. Everything else goes to email.</li>
<li><strong>Team of ~10:</strong> a weekly on-call rotation, one channel for pages and another for tickets, and a 15-minute review every week: for each page, was it real, and did someone need to act? Delete or downgrade any alert that fails twice.</li>
</ul>
<p>Every page should include what fired, the current value, a link to the dashboard and a runbook link with the first three things to check. A runbook can be five lines.</p>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<ul>
<li>Delete or downgrade every page that is a cause (CPU, memory, single errors) rather than a symptom.</li>
<li>Make sure three pages exist: 5xx rate, p95 on key routes, and external “down”.</li>
<li>Give each a minimum volume and a duration.</li>
<li>Review pages weekly and delete the ones nobody acted on.</li>
</ul>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="what-is-the-difference-between-a-symptom-alert-and-a-cause-alert">What is the difference between a symptom alert and a cause alert? <a class="post-heading-anchor" href="#what-is-the-difference-between-a-symptom-alert-and-a-cause-alert" aria-hidden="true">#</a></h3>
<p>A symptom alert fires on what users experience, such as errors or slow responses. A cause alert fires on something that might lead to that, such as high CPU. Page on symptoms and use causes for diagnosis.</p>
<h3 id="should-i-alert-on-p95-or-p99-latency">Should I alert on p95 or p99 latency? <a class="post-heading-anchor" href="#should-i-alert-on-p95-or-p99-latency" aria-hidden="true">#</a></h3>
<p>For paging, p95 over several minutes is steadier and harder to trip with a handful of slow requests. Watch p99 on dashboards and for tickets, especially on low-traffic routes.</p>
<h3 id="how-many-alerts-should-a-small-api-team-have">How many alerts should a small API team have? <a class="post-heading-anchor" href="#how-many-alerts-should-a-small-api-team-have" aria-hidden="true">#</a></h3>
<p>Few enough that every page gets acted on. For a single Node API, three to five paging alerts covering errors, latency, availability and capacity is a reasonable start.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>Alerts tell you something is wrong; the next ten minutes are spent working out what. If you would rather not build that investigation tooling yourself, ApiTraffic records every request and response, with route patterns and tags.</p>
<ul>
<li><a href="https://www.apitraffic.io/docs/traffic/search">Search</a> <code>status_code &gt;= 500</code> for the alert’s time window to see the failing requests themselves, with bodies and headers (credentials masked by default).</li>
<li><code>tag:userId == "1042" AND status_code &gt;= 400</code> shows whether one customer is behind the spike (<a href="https://www.apitraffic.io/docs/tags">tags and dimensions</a>).</li>
<li><a href="https://www.apitraffic.io/docs/endpoint-handling">Endpoint details</a> show p50, p95 and p99 per endpoint, and Regressions flags endpoints whose p95 rose after a recorded deploy event.</li>
</ul>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260804-api-alerting-best-practices">apitraffic.io</a></p>
<p>Sources: <a href="https://sre.google/sre-book/monitoring-distributed-systems/" target="_blank" rel="noopener noreferrer">Google SRE book, “Monitoring Distributed Systems”</a>; <a href="https://prometheus.io/docs/practices/alerting/" target="_blank" rel="noopener noreferrer">Prometheus, “Alerting” best practices</a>; <a href="https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/" target="_blank" rel="noopener noreferrer">Prometheus alerting rules</a>; <a href="https://github.com/siimon/prom-client" target="_blank" rel="noopener noreferrer">prom-client</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>API key security for Node APIs: prefixes, hashing, scopes and rotation</title>
      <link>https://www.apitraffic.io/blog/api-key-security</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/api-key-security</guid>
      <pubDate>Mon, 27 Jul 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>A customer just pushed their API key to a public repo. Can you tell if anyone used it, and could a database leak expose every key? Design API key security in Node properly: prefixes, hashing, scopes and rotation.</description>
      <content:encoded><![CDATA[<p>A customer emails to say they pushed their API key to a public GitHub repo an hour ago. Can you tell whether anyone used it? Can they rotate it without downtime? And if your database leaked tomorrow, would every customer’s key leak with it? API key security comes down to a handful of design decisions that are cheap on day one and painful to retrofit.</p>
<p>The direct answer: generate keys with at least 256 bits of randomness, give them a recognizable prefix so leaks are easy to spot, show the key once, store only a SHA-256 hash, look keys up by a separate public ID, attach scopes and an optional expiry, allow two active keys per account so rotation has no downtime, and never log the key itself. Here is how to do each in Node.</p>
<h2 id="what-a-good-api-key-looks-like">What a good API key looks like <a class="post-heading-anchor" href="#what-a-good-api-key-looks-like" aria-hidden="true">#</a></h2>
<p>The format we will build:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">text</span></div><pre><code class="language-text hljs">atk_live_5d0de6ca2ad7_fYF7sqANvWIOd9-JHGS05Snc89N4M13XeBFhunWSSjU_1aqdw5g
└─┬┘ └┬─┘ └────┬────┘ └───────────────────┬───────────────────┘ └──┬──┘
prefix env   key ID          secret (32 random bytes)          checksum
</code></pre></div>
<ul>
<li><strong>Prefix</strong> (<code>atk_</code>): makes your keys recognizable to people, grep and secret scanners. <a href="https://github.blog/engineering/platform-security/behind-githubs-new-authentication-token-formats/" target="_blank" rel="noopener noreferrer">GitHub moved to prefixed tokens like <code>ghp_</code> in 2021</a> for exactly this reason, and Stripe’s <code>sk_live_</code> / <code>sk_test_</code> keys are the familiar example.</li>
<li><strong>Environment</strong> (<code>live</code> / <code>test</code>): a test key in production code is a bug you can catch at a glance.</li>
<li><strong>Key ID</strong>: a short, non-secret identifier used to find the row and to show the key in your dashboard.</li>
<li><strong>Secret</strong>: 32 bytes from <a href="https://nodejs.org/api/crypto.html" target="_blank" rel="noopener noreferrer"><code>crypto.randomBytes</code></a>, base64url encoded. This is the only part that needs protecting.</li>
<li><strong>Checksum</strong>: a CRC32 of the rest, so you can reject typos and truncated keys without a database query. GitHub’s token format includes a checksum for the same reason.</li>
</ul>
<h2 id="how-to-generate-api-keys-in-node-js">How to generate API keys in Node.js <a class="post-heading-anchor" href="#how-to-generate-api-keys-in-node-js" aria-hidden="true">#</a></h2>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// api-keys.js</span>
<span class="hljs-keyword">import</span> { randomBytes, createHash, timingSafeEqual } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:crypto'</span>;
<span class="hljs-keyword">import</span> { crc32 } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:zlib'</span>; <span class="hljs-comment">// Node 20.15+ / 22.2+</span>

<span class="hljs-keyword">const</span> <span class="hljs-variable">PREFIX</span> = <span class="hljs-string">'atk'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">generateApiKey</span>(<span class="hljs-params">env = <span class="hljs-string">'live'</span></span>) {
  <span class="hljs-keyword">const</span> id = <span class="hljs-title">randomBytes</span>(<span class="hljs-number">6</span>).<span class="hljs-title">toString</span>(<span class="hljs-string">'hex'</span>);
  <span class="hljs-keyword">const</span> secret = <span class="hljs-title">randomBytes</span>(<span class="hljs-number">32</span>).<span class="hljs-title">toString</span>(<span class="hljs-string">'base64url'</span>);
  <span class="hljs-keyword">const</span> body = <span class="hljs-string">`<span class="hljs-subst">${PREFIX}</span>_<span class="hljs-subst">${env}</span>_<span class="hljs-subst">${id}</span>_<span class="hljs-subst">${secret}</span>`</span>;
  <span class="hljs-keyword">const</span> checksum = <span class="hljs-title">crc32</span>(body).<span class="hljs-title">toString</span>(<span class="hljs-number">36</span>).<span class="hljs-title">padStart</span>(<span class="hljs-number">7</span>, <span class="hljs-string">'0'</span>);
  <span class="hljs-keyword">return</span> {
    <span class="hljs-attr">key</span>: <span class="hljs-string">`<span class="hljs-subst">${body}</span>_<span class="hljs-subst">${checksum}</span>`</span>, <span class="hljs-comment">// show once, never store</span>
    id,
    <span class="hljs-attr">hash</span>: <span class="hljs-title">createHash</span>(<span class="hljs-string">'sha256'</span>).<span class="hljs-title">update</span>(secret).<span class="hljs-title">digest</span>(<span class="hljs-string">'hex'</span>),
  };
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">parseApiKey</span>(<span class="hljs-params">key</span>) {
  <span class="hljs-keyword">const</span> m = <span class="hljs-regexp">/^atk_(live|test)_([0-9a-f]{12})_([A-Za-z0-9_-]{43})_([0-9a-z]{7})$/</span>.<span class="hljs-title">exec</span>(key ?? <span class="hljs-string">''</span>);
  <span class="hljs-keyword">if</span> (!m) <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
  <span class="hljs-keyword">const</span> [, env, id, secret, checksum] = m;
  <span class="hljs-keyword">const</span> expected = <span class="hljs-title">crc32</span>(<span class="hljs-string">`<span class="hljs-subst">${PREFIX}</span>_<span class="hljs-subst">${env}</span>_<span class="hljs-subst">${id}</span>_<span class="hljs-subst">${secret}</span>`</span>).<span class="hljs-title">toString</span>(<span class="hljs-number">36</span>).<span class="hljs-title">padStart</span>(<span class="hljs-number">7</span>, <span class="hljs-string">'0'</span>);
  <span class="hljs-keyword">return</span> checksum === expected ? { env, id, secret } : <span class="hljs-literal">null</span>;
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">secretMatches</span>(<span class="hljs-params">secret, storedHashHex</span>) {
  <span class="hljs-keyword">const</span> a = <span class="hljs-title">createHash</span>(<span class="hljs-string">'sha256'</span>).<span class="hljs-title">update</span>(secret).<span class="hljs-title">digest</span>();
  <span class="hljs-keyword">const</span> b = <span class="hljs-title">Buffer</span>.<span class="hljs-title">from</span>(storedHashHex, <span class="hljs-string">'hex'</span>);
  <span class="hljs-keyword">return</span> a.<span class="hljs-property">length</span> === b.<span class="hljs-property">length</span> &amp;&amp; <span class="hljs-title">timingSafeEqual</span>(a, b);
}
</code></pre></div>
<h2 id="why-hash-api-keys-with-sha-256-not-bcrypt">Why hash API keys with SHA-256, not bcrypt <a class="post-heading-anchor" href="#why-hash-api-keys-with-sha-256-not-bcrypt" aria-hidden="true">#</a></h2>
<p>Passwords need slow hashes (bcrypt, scrypt, Argon2) because people choose guessable passwords. A 256-bit random secret cannot be guessed, so a fast hash such as SHA-256 is enough: a stolen hash is useless without the secret. A slow hash would add tens of milliseconds to every authenticated request for no security gain.</p>
<p>Storing only the hash means a database dump, a backup in the wrong bucket or a support engineer’s SQL query does not expose working keys. It also means you cannot show the key again; users must copy it when it is created.</p>
<p>A minimal table:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">sql</span></div><pre><code class="language-sql hljs"><span class="hljs-keyword">CREATE TABLE</span> api_keys (
  id            text <span class="hljs-keyword">PRIMARY KEY</span>,          <span class="hljs-comment">-- the public key ID</span>
  account_id    <span class="hljs-type">bigint</span> <span class="hljs-keyword">NOT NULL</span> <span class="hljs-keyword">REFERENCES</span> accounts(id),
  secret_hash   text <span class="hljs-keyword">NOT NULL</span>,             <span class="hljs-comment">-- sha256 hex</span>
  env           text <span class="hljs-keyword">NOT NULL</span>,             <span class="hljs-comment">-- 'live' | 'test'</span>
  scopes        text[] <span class="hljs-keyword">NOT NULL</span> <span class="hljs-keyword">DEFAULT</span> <span class="hljs-string">'{}'</span>,
  name          text,                      <span class="hljs-comment">-- "CI deploy key"</span>
  created_at    timestamptz <span class="hljs-keyword">NOT NULL</span> <span class="hljs-keyword">DEFAULT</span> now(),
  expires_at    timestamptz,
  last_used_at  timestamptz,
  revoked_at    timestamptz
);
</code></pre></div>
<h2 id="how-to-verify-api-keys-in-express">How to verify API keys in Express <a class="post-heading-anchor" href="#how-to-verify-api-keys-in-express" aria-hidden="true">#</a></h2>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> { parseApiKey, secretMatches } <span class="hljs-keyword">from</span> <span class="hljs-string">'./api-keys.js'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title">apiKeyAuth</span>(<span class="hljs-params">req, res, next</span>) {
  <span class="hljs-keyword">const</span> parsed = <span class="hljs-title">parseApiKey</span>(req.<span class="hljs-title">get</span>(<span class="hljs-string">'x-api-key'</span>));
  <span class="hljs-keyword">if</span> (!parsed) <span class="hljs-keyword">return</span> res.<span class="hljs-title">status</span>(<span class="hljs-number">401</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'invalid_api_key'</span> });

  <span class="hljs-keyword">const</span> row = <span class="hljs-keyword">await</span> db.<span class="hljs-property">apiKeys</span>.<span class="hljs-title">findById</span>(parsed.<span class="hljs-property">id</span>);
  <span class="hljs-keyword">const</span> valid =
    row &amp;&amp;
    !row.<span class="hljs-property">revoked_at</span> &amp;&amp;
    (!row.<span class="hljs-property">expires_at</span> || row.<span class="hljs-property">expires_at</span> &gt; <span class="hljs-keyword">new</span> <span class="hljs-title">Date</span>()) &amp;&amp;
    row.<span class="hljs-property">env</span> === parsed.<span class="hljs-property">env</span> &amp;&amp;
    <span class="hljs-title">secretMatches</span>(parsed.<span class="hljs-property">secret</span>, row.<span class="hljs-property">secret_hash</span>);

  <span class="hljs-keyword">if</span> (!valid) <span class="hljs-keyword">return</span> res.<span class="hljs-title">status</span>(<span class="hljs-number">401</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'invalid_api_key'</span> });

  req.<span class="hljs-property">apiKey</span> = { <span class="hljs-attr">id</span>: row.<span class="hljs-property">id</span>, <span class="hljs-attr">accountId</span>: row.<span class="hljs-property">account_id</span>, <span class="hljs-attr">scopes</span>: <span class="hljs-keyword">new</span> <span class="hljs-title">Set</span>(row.<span class="hljs-property">scopes</span>) };
  db.<span class="hljs-property">apiKeys</span>.<span class="hljs-title">touchLastUsed</span>(row.<span class="hljs-property">id</span>).<span class="hljs-title">catch</span>(<span class="hljs-function">() =&gt;</span> {}); <span class="hljs-comment">// throttle this in production</span>
  <span class="hljs-title">next</span>();
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title">requireScope</span> = (<span class="hljs-params">scope</span>) =&gt; <span class="hljs-function">(<span class="hljs-params">req, res, next</span>) =&gt;</span>
  req.<span class="hljs-property">apiKey</span>?.<span class="hljs-property">scopes</span>.<span class="hljs-title">has</span>(scope) ? <span class="hljs-title">next</span>() : res.<span class="hljs-title">status</span>(<span class="hljs-number">403</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'insufficient_scope'</span> });

<span class="hljs-comment">// usage</span>
app.<span class="hljs-title">get</span>(<span class="hljs-string">'/v1/orders'</span>, apiKeyAuth, <span class="hljs-title">requireScope</span>(<span class="hljs-string">'orders:read'</span>), listOrders);
</code></pre></div>
<p>Return the same <code>401</code> for “malformed”, “not found”, “revoked” and “wrong secret”, so callers cannot probe which keys exist. Use <code>403</code> only when the key is valid but lacks the scope.</p>
<h2 id="scopes-least-privilege-for-api-keys">Scopes: least privilege for API keys <a class="post-heading-anchor" href="#scopes-least-privilege-for-api-keys" aria-hidden="true">#</a></h2>
<p>Most API keys end up in CI systems, Zapier, cron jobs and contractors’ laptops. Scopes limit the damage when one leaks. Start simple: <code>resource:read</code> and <code>resource:write</code> per resource, plus a “full access” option for users who insist. Default new keys to read-only and make write access a deliberate choice.</p>
<h2 id="how-to-rotate-api-keys-without-downtime">How to rotate API keys without downtime <a class="post-heading-anchor" href="#how-to-rotate-api-keys-without-downtime" aria-hidden="true">#</a></h2>
<p>Rotation fails when an account can have only one key. Allow at least two active keys, then:</p>
<ol>
<li>Create the new key and give it to the client.</li>
<li>The client deploys it.</li>
<li>Watch <code>last_used_at</code> on the old key until it stops changing.</li>
<li>Revoke the old key.</li>
</ol>
<p>Optional expiry (90 days, say) forces this habit for high-risk keys. Showing <code>last_used_at</code> and the key name in your dashboard turns “which of these keys is safe to delete?” into an easy question.</p>
<p>When a key leaks, the order is: revoke immediately, issue a replacement, then check what the leaked key did. That last step needs request logs tied to the key ID, which is a good reason to keep them.</p>
<h2 id="do-not-let-api-keys-leak-through-your-own-logs">Do not let API keys leak through your own logs <a class="post-heading-anchor" href="#do-not-let-api-keys-leak-through-your-own-logs" aria-hidden="true">#</a></h2>
<p>The most common leak is not GitHub; it is your logs. Mask <code>X-Api-Key</code> and <code>Authorization</code> in request logging (see <a href="/blog/redact-sensitive-data-logs">how to redact sensitive data in logs</a>), log the key ID instead of the key, and never put keys in URLs, where proxies and analytics record them. Pair key auth with <a href="/blog/nodejs-rate-limiting">rate limiting per API key</a> so a leaked key cannot be used at unlimited speed.</p>
<h2 id="api-key-security-checklist">API key security checklist <a class="post-heading-anchor" href="#api-key-security-checklist" aria-hidden="true">#</a></h2>
<ul>
<li>[ ] 32 random bytes per key; prefix, environment and key ID in the format.</li>
<li>[ ] Shown once; only the SHA-256 hash stored.</li>
<li>[ ] Constant-time comparison; one generic <code>401</code>.</li>
<li>[ ] Scopes, with read-only as the default.</li>
<li>[ ] Two active keys per account; <code>last_used_at</code> visible.</li>
<li>[ ] Keys masked in all logs; key ID logged instead.</li>
<li>[ ] Rate limits per key.</li>
</ul>
<p>A solo developer can ship the first four in an afternoon, before the first customer gets a key. A team of ten should also write the leak runbook (revoke, replace, review usage) and consider registering the prefix with <a href="https://docs.github.com/en/code-security/secret-scanning/secret-scanning-partnership-program/secret-scanning-partner-program" target="_blank" rel="noopener noreferrer">GitHub’s secret scanning partner program</a> so leaked keys are reported to you.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="should-i-hash-api-keys-with-bcrypt">Should I hash API keys with bcrypt? <a class="post-heading-anchor" href="#should-i-hash-api-keys-with-bcrypt" aria-hidden="true">#</a></h3>
<p>No, not if keys are long random values. bcrypt protects low-entropy passwords from guessing; a 256-bit random secret cannot be guessed, so SHA-256 is enough and keeps authentication fast.</p>
<h3 id="should-api-keys-expire">Should API keys expire? <a class="post-heading-anchor" href="#should-api-keys-expire" aria-hidden="true">#</a></h3>
<p>Make expiry optional and encourage it for high-risk keys. Forced short expiry on every key mostly produces outages when someone forgets to rotate; supporting two active keys and showing last use gets you most of the benefit.</p>
<h3 id="where-should-clients-send-the-api-key">Where should clients send the API key? <a class="post-heading-anchor" href="#where-should-clients-send-the-api-key" aria-hidden="true">#</a></h3>
<p>In a header such as <code>Authorization: Bearer ...</code> or <code>X-Api-Key</code>, never in the query string. URLs end up in access logs, browser history and analytics tools.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not build masking, per-key limits and usage tracking yourself, ApiTraffic covers the parts around your key auth. <code>Authorization</code>, <code>Cookie</code> and <code>X-Api-Key</code> are <a href="https://www.apitraffic.io/docs/data/redactions">masked by default</a> in the request log. <a href="https://www.apitraffic.io/docs/waf/policies">Firewall rate limits</a> can be keyed on a header, so each API key gets its own limit, with <code>429</code> and <code>Retry-After</code> for callers over it.</p>
<p><a href="https://www.apitraffic.io/docs/tags">Tag each request</a> with the account, <code>apitraffic.tag('accountId', String(req.apiKey.accountId))</code>, and the “did anyone use the leaked key?” question becomes a <a href="https://www.apitraffic.io/docs/traffic/search">search</a>: <code>tag:accountId == "1042" AND status_code &gt;= 400</code>, or the same tag over the last day.</p>
<p>Start free and see every request each API key makes: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260727-api-key-security">apitraffic.io</a></p>
<p>Sources: <a href="https://github.blog/engineering/platform-security/behind-githubs-new-authentication-token-formats/" target="_blank" rel="noopener noreferrer">GitHub: Behind GitHub’s new authentication token formats (April 2021)</a>; <a href="https://nodejs.org/api/crypto.html" target="_blank" rel="noopener noreferrer">Node.js crypto: randomBytes and timingSafeEqual</a>; <a href="https://nodejs.org/api/zlib.html#zlibcrc32data-value" target="_blank" rel="noopener noreferrer">Node.js zlib.crc32</a>; <a href="https://owasp.org/API-Security/editions/2023/en/0xa2-broken-authentication/" target="_blank" rel="noopener noreferrer">OWASP API Security Top 10 2023: API2 Broken Authentication</a>; <a href="https://docs.github.com/en/code-security/secret-scanning/secret-scanning-partnership-program/secret-scanning-partner-program" target="_blank" rel="noopener noreferrer">GitHub secret scanning partner program</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Zod validation in Express and Fastify: request bodies, query strings and useful errors</title>
      <link>https://www.apitraffic.io/blog/zod-validation-express-fastify</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/zod-validation-express-fastify</guid>
      <pubDate>Fri, 17 Jul 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>A null where your handler expected an array becomes a 500 and a support ticket. Validate bodies, query strings and params with Zod 4 in Express 5 and Fastify 5, return 400s clients can act on, and stop extra fields at the door.</description>
      <content:encoded><![CDATA[<p>The bug report says “500 on checkout”, and the stack trace ends in <code>Cannot read properties of undefined (reading 'map')</code> because a client sent <code>items: null</code>. The handler trusted <code>req.body</code>. Validating input at the edge turns that 500 into a clear 400 the client can fix, and stops unexpected fields from reaching your database.</p>
<p>The short answer: define a Zod schema per route for the body, query and params; in <a href="https://www.apitraffic.io/express">Express</a>, run <code>safeParse</code> in a small middleware and return a 400 with the issue paths; in <a href="https://www.apitraffic.io/fastify">Fastify</a>, plug Zod into the schema system with <a href="https://github.com/turkerdev/fastify-type-provider-zod" target="_blank" rel="noopener noreferrer"><code>fastify-type-provider-zod</code></a> so validation, types and response filtering come from one definition. This post uses <a href="https://zod.dev/v4" target="_blank" rel="noopener noreferrer">Zod 4</a> (stable since July 2025), Express 5 and Fastify 5.</p>
<h2 id="why-zod-validation-for-request-bodies">Why Zod validation for request bodies <a class="post-heading-anchor" href="#why-zod-validation-for-request-bodies" aria-hidden="true">#</a></h2>
<p>Zod gives you three things from one schema: a runtime check, a TypeScript type (<code>z.infer</code>), and normalized data (coerced numbers, defaults, stripped unknown keys). Zod 4 also moved string formats to the top level (<code>z.email()</code>, <code>z.uuid()</code>, <code>z.url()</code>) and unified error customization under a single <code>error</code> parameter, and the maintainers’ benchmarks show object parsing several times faster than Zod 3.</p>
<p>Here are schemas for an order API that the Express and Fastify examples both use:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">ts</span></div><pre><code class="language-ts hljs"><span class="hljs-comment">// schemas.ts</span>
<span class="hljs-keyword">import</span> { z } <span class="hljs-keyword">from</span> <span class="hljs-string">'zod'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title">CreateOrder</span> = z.<span class="hljs-title">strictObject</span>({
  <span class="hljs-attr">customerId</span>: z.<span class="hljs-title">uuid</span>(),
  <span class="hljs-attr">email</span>: z.<span class="hljs-title">email</span>(),
  <span class="hljs-attr">items</span>: z
    .<span class="hljs-title">array</span>(z.<span class="hljs-title">object</span>({ <span class="hljs-attr">sku</span>: z.<span class="hljs-title">string</span>().<span class="hljs-title">min</span>(<span class="hljs-number">1</span>).<span class="hljs-title">max</span>(<span class="hljs-number">64</span>), <span class="hljs-attr">qty</span>: z.<span class="hljs-title">int</span>().<span class="hljs-title">min</span>(<span class="hljs-number">1</span>).<span class="hljs-title">max</span>(<span class="hljs-number">100</span>) }))
    .<span class="hljs-title">min</span>(<span class="hljs-number">1</span>)
    .<span class="hljs-title">max</span>(<span class="hljs-number">50</span>),
  <span class="hljs-attr">note</span>: z.<span class="hljs-title">string</span>().<span class="hljs-title">max</span>(<span class="hljs-number">500</span>).<span class="hljs-title">optional</span>(),
});
<span class="hljs-keyword">export</span> <span class="hljs-keyword">type</span> <span class="hljs-title">CreateOrderInput</span> = z.<span class="hljs-property">infer</span>&lt;<span class="hljs-keyword">typeof</span> <span class="hljs-title">CreateOrder</span>&gt;;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title">ListOrders</span> = z.<span class="hljs-title">object</span>({
  <span class="hljs-attr">limit</span>: z.<span class="hljs-property">coerce</span>.<span class="hljs-title">number</span>().<span class="hljs-title">int</span>().<span class="hljs-title">min</span>(<span class="hljs-number">1</span>).<span class="hljs-title">max</span>(<span class="hljs-number">100</span>).<span class="hljs-title">default</span>(<span class="hljs-number">20</span>),
  <span class="hljs-attr">cursor</span>: z.<span class="hljs-title">string</span>().<span class="hljs-title">max</span>(<span class="hljs-number">200</span>).<span class="hljs-title">optional</span>(),
});

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title">OrderParams</span> = z.<span class="hljs-title">object</span>({ <span class="hljs-attr">id</span>: z.<span class="hljs-title">uuid</span>() });

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title">OrderOut</span> = z.<span class="hljs-title">object</span>({
  <span class="hljs-attr">id</span>: z.<span class="hljs-title">uuid</span>(),
  <span class="hljs-attr">status</span>: z.<span class="hljs-title">enum</span>([<span class="hljs-string">'pending'</span>, <span class="hljs-string">'paid'</span>, <span class="hljs-string">'shipped'</span>]),
  <span class="hljs-attr">total</span>: z.<span class="hljs-title">int</span>(),
});
</code></pre></div>
<p>Three choices in there matter for security, not just tidiness:</p>
<ul>
<li><code>z.strictObject</code> rejects unknown keys. A plain <code>z.object</code> strips them silently, which is also safe; what is not safe is spreading the raw <code>req.body</code> into an insert, because then a client can send <code>role: "admin"</code> or <code>accountId</code> and you have a mass-assignment bug.</li>
<li>Every string and array has a <code>max</code>. Validation is your cheapest defense against a 5 MB <code>note</code> field.</li>
<li>Query values arrive as strings, so <code>z.coerce.number()</code> turns <code>?limit=20</code> into <code>20</code>.</li>
</ul>
<h2 id="zod-validation-middleware-for-express-5">Zod validation middleware for Express 5 <a class="post-heading-anchor" href="#zod-validation-middleware-for-express-5" aria-hidden="true">#</a></h2>
<p>Express has no schema system, so you write one small middleware:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">ts</span></div><pre><code class="language-ts hljs"><span class="hljs-comment">// validate.ts</span>
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { <span class="hljs-title">RequestHandler</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { <span class="hljs-title">ZodType</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'zod'</span>;

<span class="hljs-keyword">type</span> <span class="hljs-title">Parts</span> = { <span class="hljs-attr">params</span>?: <span class="hljs-title">ZodType</span>; <span class="hljs-attr">query</span>?: <span class="hljs-title">ZodType</span>; <span class="hljs-attr">body</span>?: <span class="hljs-title">ZodType</span> };

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> validate = (<span class="hljs-attr">schemas</span>: <span class="hljs-title">Parts</span>): <span class="hljs-function"><span class="hljs-params">RequestHandler</span> =&gt;</span> <span class="hljs-function">(<span class="hljs-params">req, res, next</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> <span class="hljs-attr">input</span>: <span class="hljs-title">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">unknown</span>&gt; = {};
  <span class="hljs-keyword">const</span> <span class="hljs-attr">issues</span>: { <span class="hljs-attr">in</span>: <span class="hljs-built_in">string</span>; <span class="hljs-attr">path</span>: <span class="hljs-built_in">string</span>; <span class="hljs-attr">code</span>: <span class="hljs-built_in">string</span>; <span class="hljs-attr">message</span>: <span class="hljs-built_in">string</span> }[] = [];

  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> part <span class="hljs-keyword">of</span> [<span class="hljs-string">'params'</span>, <span class="hljs-string">'query'</span>, <span class="hljs-string">'body'</span>] <span class="hljs-keyword">as</span> <span class="hljs-keyword">const</span>) {
    <span class="hljs-keyword">const</span> schema = schemas[part];
    <span class="hljs-keyword">if</span> (!schema) <span class="hljs-keyword">continue</span>;
    <span class="hljs-keyword">const</span> result = schema.<span class="hljs-title">safeParse</span>(req[part]);
    <span class="hljs-keyword">if</span> (result.<span class="hljs-property">success</span>) input[part] = result.<span class="hljs-property">data</span>;
    <span class="hljs-keyword">else</span> <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> i <span class="hljs-keyword">of</span> result.<span class="hljs-property">error</span>.<span class="hljs-property">issues</span>) {
      issues.<span class="hljs-title">push</span>({ <span class="hljs-attr">in</span>: part, <span class="hljs-attr">path</span>: i.<span class="hljs-property">path</span>.<span class="hljs-title">join</span>(<span class="hljs-string">'.'</span>), <span class="hljs-attr">code</span>: i.<span class="hljs-property">code</span>, <span class="hljs-attr">message</span>: i.<span class="hljs-property">message</span> });
    }
  }

  <span class="hljs-keyword">if</span> (issues.<span class="hljs-property">length</span>) <span class="hljs-keyword">return</span> res.<span class="hljs-title">status</span>(<span class="hljs-number">400</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'invalid request'</span>, issues });
  res.<span class="hljs-property">locals</span>.<span class="hljs-property">input</span> = input;   <span class="hljs-comment">// req.query is a read-only getter in Express 5</span>
  <span class="hljs-title">next</span>();
};
</code></pre></div>
<p>Use it per route:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">ts</span></div><pre><code class="language-ts hljs">app.<span class="hljs-title">use</span>(express.<span class="hljs-title">json</span>({ <span class="hljs-attr">limit</span>: <span class="hljs-string">'100kb'</span> }));

app.<span class="hljs-title">post</span>(<span class="hljs-string">'/v1/orders'</span>, authenticate, <span class="hljs-title">validate</span>({ <span class="hljs-attr">body</span>: <span class="hljs-title">CreateOrder</span> }), <span class="hljs-title">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> body = res.<span class="hljs-property">locals</span>.<span class="hljs-property">input</span>.<span class="hljs-property">body</span> <span class="hljs-keyword">as</span> <span class="hljs-title">CreateOrderInput</span>;
  <span class="hljs-keyword">const</span> order = <span class="hljs-keyword">await</span> <span class="hljs-title">createOrder</span>(req.<span class="hljs-property">user</span>.<span class="hljs-property">accountId</span>, body);
  res.<span class="hljs-title">status</span>(<span class="hljs-number">201</span>).<span class="hljs-title">json</span>(<span class="hljs-title">OrderOut</span>.<span class="hljs-title">parse</span>(order));
});

app.<span class="hljs-title">get</span>(<span class="hljs-string">'/v1/orders'</span>, authenticate, <span class="hljs-title">validate</span>({ <span class="hljs-attr">query</span>: <span class="hljs-title">ListOrders</span> }), <span class="hljs-title">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> { limit, cursor } = res.<span class="hljs-property">locals</span>.<span class="hljs-property">input</span>.<span class="hljs-property">query</span>;
  res.<span class="hljs-title">json</span>(<span class="hljs-keyword">await</span> <span class="hljs-title">listOrders</span>(req.<span class="hljs-property">user</span>.<span class="hljs-property">accountId</span>, limit, cursor));
});
</code></pre></div>
<h3 id="the-express-5-gotcha-req-query-is-read-only">The Express 5 gotcha: req.query is read-only <a class="post-heading-anchor" href="#the-express-5-gotcha-req-query-is-read-only" aria-hidden="true">#</a></h3>
<p>In Express 4, plenty of validation middleware did <code>req.query = parsed</code>. <a href="https://expressjs.com/en/guide/migrating-5.html" target="_blank" rel="noopener noreferrer">In Express 5, <code>req.query</code> is a getter</a>, so that assignment does not stick. Put parsed values somewhere else (<code>res.locals</code> above, or a typed <code>req.input</code> you declare yourself). Express 5’s default query parser is also now “simple”, so <code>?filter[status]=paid</code> no longer becomes a nested object unless you set <code>app.set('query parser', 'extended')</code>. If you are still moving over, our <a href="/blog/express-5-migration">Express 5 migration guide</a> covers the rest.</p>
<h3 id="safeparse-or-parse">safeParse or parse? <a class="post-heading-anchor" href="#safeparse-or-parse" aria-hidden="true">#</a></h3>
<p>Use <code>safeParse</code> in middleware so a bad request is a normal 400, not an exception. Use <code>parse</code> on data you produced, like <code>OrderOut.parse(order)</code> above, where a failure really is a server bug and should be a 500.</p>
<h2 id="zod-validation-in-fastify-with-a-type-provider">Zod validation in Fastify with a type provider <a class="post-heading-anchor" href="#zod-validation-in-fastify-with-a-type-provider" aria-hidden="true">#</a></h2>
<p>Fastify validates with JSON Schema and Ajv by default. <code>fastify-type-provider-zod</code> swaps in Zod and gives your handlers typed <code>request.body</code>, <code>request.query</code> and <code>request.params</code>. Versions 5 and 6 of that package need Zod 4; version 7 (June 2026) needs Zod 4.2 or later.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">ts</span></div><pre><code class="language-ts hljs"><span class="hljs-keyword">import</span> <span class="hljs-title">Fastify</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'fastify'</span>;
<span class="hljs-keyword">import</span> {
  serializerCompiler,
  validatorCompiler,
  hasZodFastifySchemaValidationErrors,
  <span class="hljs-keyword">type</span> <span class="hljs-title">ZodTypeProvider</span>,
} <span class="hljs-keyword">from</span> <span class="hljs-string">'fastify-type-provider-zod'</span>;
<span class="hljs-keyword">import</span> { <span class="hljs-title">CreateOrder</span>, <span class="hljs-title">OrderOut</span>, <span class="hljs-title">OrderParams</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'./schemas.js'</span>;

<span class="hljs-keyword">const</span> app = <span class="hljs-title">Fastify</span>({ <span class="hljs-attr">logger</span>: <span class="hljs-literal">true</span> }).<span class="hljs-property">withTypeProvider</span>&lt;<span class="hljs-title">ZodTypeProvider</span>&gt;();
app.<span class="hljs-title">setValidatorCompiler</span>(validatorCompiler);
app.<span class="hljs-title">setSerializerCompiler</span>(serializerCompiler);

app.<span class="hljs-title">setErrorHandler</span>(<span class="hljs-function">(<span class="hljs-params">err, request, reply</span>) =&gt;</span> {
  <span class="hljs-keyword">if</span> (<span class="hljs-title">hasZodFastifySchemaValidationErrors</span>(err)) {
    <span class="hljs-keyword">return</span> reply.<span class="hljs-title">code</span>(<span class="hljs-number">400</span>).<span class="hljs-title">send</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'invalid request'</span>, <span class="hljs-attr">issues</span>: err.<span class="hljs-property">validation</span> });
  }
  request.<span class="hljs-property">log</span>.<span class="hljs-title">error</span>({ err }, <span class="hljs-string">'unhandled error'</span>);
  <span class="hljs-keyword">return</span> reply.<span class="hljs-title">code</span>(<span class="hljs-number">500</span>).<span class="hljs-title">send</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'internal error'</span> });
});

app.<span class="hljs-title">post</span>(<span class="hljs-string">'/v1/orders'</span>, {
  <span class="hljs-attr">schema</span>: { <span class="hljs-attr">body</span>: <span class="hljs-title">CreateOrder</span>, <span class="hljs-attr">response</span>: { <span class="hljs-number">201</span>: <span class="hljs-title">OrderOut</span> } },
}, <span class="hljs-title">async</span> (request, reply) =&gt; {
  <span class="hljs-keyword">const</span> order = <span class="hljs-keyword">await</span> <span class="hljs-title">createOrder</span>(request.<span class="hljs-property">body</span>);   <span class="hljs-comment">// typed as CreateOrderInput</span>
  <span class="hljs-keyword">return</span> reply.<span class="hljs-title">code</span>(<span class="hljs-number">201</span>).<span class="hljs-title">send</span>(order);
});

app.<span class="hljs-title">get</span>(<span class="hljs-string">'/v1/orders/:id'</span>, {
  <span class="hljs-attr">schema</span>: { <span class="hljs-attr">params</span>: <span class="hljs-title">OrderParams</span>, <span class="hljs-attr">response</span>: { <span class="hljs-number">200</span>: <span class="hljs-title">OrderOut</span> } },
}, <span class="hljs-title">async</span> (request) =&gt; <span class="hljs-title">getOrder</span>(request.<span class="hljs-property">params</span>.<span class="hljs-property">id</span>));
</code></pre></div>
<p>The <code>response</code> schema is worth the extra lines. If <code>getOrder</code> returns a row with <code>internal_notes</code> or <code>stripe_customer_id</code>, the serializer drops fields that are not in <code>OrderOut</code>. That is a cheap guard against leaking columns, the same class of bug as the object-level access mistakes in our <a href="/blog/bola-vulnerability-node-api">BOLA and IDOR post</a>.</p>
<p>Validation runs before <code>preHandler</code>, so by the time your authorization hook runs, the body is already checked; see <a href="/blog/fastify-hooks-logging-auth">Fastify hooks for logging and auth</a>.</p>
<h3 id="zod-or-fastifys-built-in-json-schema">Zod or Fastify’s built-in JSON Schema? <a class="post-heading-anchor" href="#zod-or-fastifys-built-in-json-schema" aria-hidden="true">#</a></h3>
<p>If raw throughput on large bodies is your bottleneck, Fastify’s compiled Ajv validators are hard to beat, and Zod 4 can emit JSON Schema with <code>z.toJSONSchema()</code> if you want to share definitions. For most small teams, one schema language across Express, Fastify and the front end is worth more than the difference.</p>
<h2 id="what-to-log-when-validation-fails">What to log when validation fails <a class="post-heading-anchor" href="#what-to-log-when-validation-fails" aria-hidden="true">#</a></h2>
<p>Log the issue paths and codes, not the rejected values. A 400 on <code>/v1/login</code> with the full body logged is a password in your logs. A line like <code>{ route: "/v1/orders", status: 400, issues: ["items.0.qty"] }</code> is enough to spot a broken client release.</p>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<ul>
<li>Add a schema to every route that writes data, using <code>z.strictObject</code> or plain <code>z.object</code> and never spreading raw <code>req.body</code>.</li>
<li>Put a <code>max</code> on every string and array, and cap body size in the parser.</li>
<li>Return 400 with <code>{ in, path, code, message }</code> issues.</li>
<li>In Fastify, add a <code>response</code> schema to endpoints that return database rows.</li>
</ul>
<p>A one-person team should start with the write endpoints and add reads later. A ten-person team should share <code>schemas.ts</code> with the client code so a breaking change fails in CI rather than in production.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="how-do-i-validate-query-parameters-with-zod">How do I validate query parameters with Zod? <a class="post-heading-anchor" href="#how-do-i-validate-query-parameters-with-zod" aria-hidden="true">#</a></h3>
<p>Use <code>z.coerce.number()</code> and <code>z.coerce.boolean()</code> with care, add <code>.default()</code> for optional values, and in Express 5 store the result outside <code>req.query</code>, since it cannot be reassigned.</p>
<h3 id="should-validation-errors-return-400-or-422">Should validation errors return 400 or 422? <a class="post-heading-anchor" href="#should-validation-errors-return-400-or-422" aria-hidden="true">#</a></h3>
<p>Either is defensible; pick one and document it. Many APIs use 400 for malformed input and keep 422 for well-formed requests that break a business rule.</p>
<h3 id="does-zod-strip-unknown-fields">Does Zod strip unknown fields? <a class="post-heading-anchor" href="#does-zod-strip-unknown-fields" aria-hidden="true">#</a></h3>
<p><code>z.object</code> strips unknown keys by default, <code>z.strictObject</code> rejects them, and <code>z.looseObject</code> passes them through. For write endpoints, strict is the most honest with clients.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not build the logging for this yourself, ApiTraffic captures every request and response under its route pattern, so a client sending bad payloads shows up with one <a href="https://www.apitraffic.io/docs/traffic/search">search</a>: <code>url_path == "/v1/orders" AND status_code == 400</code>. Bodies are recorded, credential headers are masked by default, and you can add <a href="https://www.apitraffic.io/docs/data/redactions">redaction rules</a> for any other field from the dashboard without a deploy.</p>
<p>Tag requests with the caller (<code>apitraffic.tag('userId', String(req.user.id))</code>) and you can tell one broken integration apart from a general problem.</p>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260717-zod-validation-express-fastify">apitraffic.io</a></p>
<p>Sources: <a href="https://zod.dev/v4" target="_blank" rel="noopener noreferrer">Zod documentation, “Release notes: Zod 4”</a>; <a href="https://www.infoq.com/news/2025/08/zod-v4-available" target="_blank" rel="noopener noreferrer">InfoQ, “Zod v4 available” (Aug 23, 2025)</a>; <a href="https://github.com/turkerdev/fastify-type-provider-zod" target="_blank" rel="noopener noreferrer">fastify-type-provider-zod README</a>; <a href="https://expressjs.com/en/guide/migrating-5.html" target="_blank" rel="noopener noreferrer">Express, “Migrating to Express 5”</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>API log retention and cost: how much request logging a small team needs</title>
      <link>https://www.apitraffic.io/blog/api-log-retention</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/api-log-retention</guid>
      <pubDate>Wed, 08 Jul 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>Either the logging bill surprises you or the logs are gone when a customer asks about last month. Here is a retention policy a small team can set up in an afternoon.</description>
      <content:encoded><![CDATA[<p>Two kinds of logging surprise hit small teams. The first is the bill, when log storage quietly becomes one of your bigger infrastructure costs. The second is the support ticket about a failed payment from six weeks ago, when your logs only go back seven days.</p>
<p>The short answer for API log retention: keep full request logs, including bodies, for 7 to 14 days; keep request metadata without bodies for 30 to 90 days; keep security and audit events for 12 months; and delete everything else on a schedule. Sample fast, successful reads if volume is a problem, but never sample errors. The rest of this post shows how to size that and set it up in an afternoon.</p>
<h2 id="why-api-log-retention-matters-for-small-teams">Why API log retention matters for small teams <a class="post-heading-anchor" href="#why-api-log-retention-matters-for-small-teams" aria-hidden="true">#</a></h2>
<p>Retention is a trade between three things: the questions you need to answer, what storing data costs, and what you are allowed to keep. Keeping everything forever fails the last two. Keeping a week fails the first.</p>
<p>Under GDPR, the <a href="https://gdpr-info.eu/art-5-gdpr/" target="_blank" rel="noopener noreferrer">storage limitation principle in Article 5(1)(e)</a> says personal data should be kept no longer than necessary for its purpose, and request logs usually contain personal data such as IPs, user IDs and emails in bodies. If you handle card data, <a href="https://learn.microsoft.com/en-us/entra/standards/pci-requirement-10" target="_blank" rel="noopener noreferrer">PCI DSS requirement 10.5.1</a> asks for audit log history for at least 12 months, with the most recent three months immediately available for analysis. Most small API teams are not in PCI scope, but it is a sensible reference point for security logs.</p>
<h2 id="how-long-should-you-keep-api-logs">How long should you keep API logs? <a class="post-heading-anchor" href="#how-long-should-you-keep-api-logs" aria-hidden="true">#</a></h2>
<p>Work backwards from the questions logs answer:</p>
<div class="post-table"><table>
<thead>
<tr>
<th>Question</th>
<th>How far back you need</th>
<th>What you need</th>
</tr>
</thead>
<tbody>
<tr>
<td>Why did this request fail?</td>
<td>Hours to days</td>
<td>Full request and response, bodies included</td>
</tr>
<tr>
<td>What happened to this customer’s order?</td>
<td>A billing cycle, about 30 days</td>
<td>Method, path, status, latency, user ID</td>
</tr>
<tr>
<td>Did a deploy make this endpoint slower?</td>
<td>Weeks</td>
<td>Per-endpoint latency and error counts</td>
</tr>
<tr>
<td>Was this account accessed by someone else?</td>
<td>Months</td>
<td>Auth events, IPs, user agents</td>
</tr>
<tr>
<td>Prove who changed what, for an audit</td>
<td>12 months or more</td>
<td>Audit events for sensitive actions</td>
</tr>
</tbody>
</table></div>
<p>Only the first row needs bodies. That single observation is most of the savings.</p>
<h2 id="how-to-estimate-request-logging-costs">How to estimate request logging costs <a class="post-heading-anchor" href="#how-to-estimate-request-logging-costs" aria-hidden="true">#</a></h2>
<p>Do the arithmetic before you pick numbers. Request volume times bytes per entry times days:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// log-size.mjs</span>
<span class="hljs-keyword">const</span> rps = <span class="hljs-number">20</span>;                <span class="hljs-comment">// average, not peak</span>
<span class="hljs-keyword">const</span> perDay = rps * <span class="hljs-number">86_400</span>;   <span class="hljs-comment">// 1,728,000 requests</span>
<span class="hljs-keyword">const</span> sizes = { <span class="hljs-attr">metadata</span>: <span class="hljs-number">1_000</span>, <span class="hljs-attr">withBodies</span>: <span class="hljs-number">4_000</span> }; <span class="hljs-comment">// bytes per entry</span>

<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [kind, bytes] <span class="hljs-keyword">of</span> <span class="hljs-title">Object</span>.<span class="hljs-title">entries</span>(sizes)) {
  <span class="hljs-keyword">const</span> gbPerDay = (perDay * bytes) / <span class="hljs-number">1e9</span>;
  <span class="hljs-variable">console</span>.<span class="hljs-title">log</span>(kind, {
    <span class="hljs-attr">gbPerDay</span>: gbPerDay.<span class="hljs-title">toFixed</span>(<span class="hljs-number">2</span>),
    <span class="hljs-attr">gb14d</span>: (gbPerDay * <span class="hljs-number">14</span>).<span class="hljs-title">toFixed</span>(<span class="hljs-number">0</span>),
    <span class="hljs-attr">gb90d</span>: (gbPerDay * <span class="hljs-number">90</span>).<span class="hljs-title">toFixed</span>(<span class="hljs-number">0</span>),
  });
}
</code></pre></div>
<p>At 20 requests per second, metadata is about 1.7 GB a day and full entries about 6.9 GB a day. Ninety days of full entries is over 600 GB. Fourteen days of full entries plus ninety days of metadata is about 250 GB. Measure your own entry sizes rather than trusting these, because a few endpoints with large JSON responses can triple the average. Then apply your provider’s price per GB ingested and stored.</p>
<h2 id="log-sampling-keep-errors-sample-the-boring-parts">Log sampling: keep errors, sample the boring parts <a class="post-heading-anchor" href="#log-sampling-keep-errors-sample-the-boring-parts" aria-hidden="true">#</a></h2>
<p>Most request volume is fast, successful reads that nobody will ever look at individually. Sampling those cuts storage without losing the requests you will investigate. The rule: decide after the response, so the outcome drives the decision.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> pino <span class="hljs-keyword">from</span> <span class="hljs-string">'pino'</span>;

<span class="hljs-keyword">const</span> log = <span class="hljs-title">pino</span>();
<span class="hljs-keyword">const</span> app = <span class="hljs-title">express</span>();
<span class="hljs-keyword">const</span> <span class="hljs-variable">SAMPLE_RATE</span> = <span class="hljs-number">0.1</span>;
<span class="hljs-keyword">const</span> <span class="hljs-variable">SLOW_MS</span> = <span class="hljs-number">1000</span>;
<span class="hljs-keyword">const</span> <span class="hljs-variable">MAX_BODY</span> = <span class="hljs-number">8</span> * <span class="hljs-number">1024</span>;

app.<span class="hljs-title">use</span>(<span class="hljs-function">(<span class="hljs-params">req, res, next</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> start = process.<span class="hljs-property">hrtime</span>.<span class="hljs-title">bigint</span>();
  res.<span class="hljs-title">on</span>(<span class="hljs-string">'finish'</span>, <span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">const</span> ms = <span class="hljs-title">Number</span>(process.<span class="hljs-property">hrtime</span>.<span class="hljs-title">bigint</span>() - start) / <span class="hljs-number">1e6</span>;
    <span class="hljs-keyword">const</span> route = req.<span class="hljs-property">route</span>?.<span class="hljs-property">path</span> ?? <span class="hljs-string">'unmatched'</span>;
    <span class="hljs-keyword">if</span> (route === <span class="hljs-string">'/livez'</span> || route === <span class="hljs-string">'/readyz'</span>) <span class="hljs-keyword">return</span>;

    <span class="hljs-keyword">const</span> keepFull =
      res.<span class="hljs-property">statusCode</span> &gt;= <span class="hljs-number">400</span> || ms &gt; <span class="hljs-variable">SLOW_MS</span> || req.<span class="hljs-property">method</span> !== <span class="hljs-string">'GET'</span> ||
      <span class="hljs-title">Math</span>.<span class="hljs-title">random</span>() &lt; <span class="hljs-variable">SAMPLE_RATE</span>;

    <span class="hljs-keyword">const</span> entry = {
      <span class="hljs-attr">method</span>: req.<span class="hljs-property">method</span>, route, <span class="hljs-attr">status</span>: res.<span class="hljs-property">statusCode</span>,
      <span class="hljs-attr">ms</span>: <span class="hljs-title">Math</span>.<span class="hljs-title">round</span>(ms), <span class="hljs-attr">userId</span>: req.<span class="hljs-property">user</span>?.<span class="hljs-property">id</span>, <span class="hljs-attr">sampled</span>: !keepFull,
    };
    <span class="hljs-keyword">if</span> (keepFull &amp;&amp; req.<span class="hljs-property">body</span>) {
      entry.<span class="hljs-property">body</span> = <span class="hljs-title">JSON</span>.<span class="hljs-title">stringify</span>(req.<span class="hljs-property">body</span>).<span class="hljs-title">slice</span>(<span class="hljs-number">0</span>, <span class="hljs-variable">MAX_BODY</span>);
    }
    log.<span class="hljs-title">info</span>(entry);
  });
  <span class="hljs-title">next</span>();
});
</code></pre></div>
<p>Every request still gets a metadata line, so counts and latency stay accurate. Only bodies are sampled. Errors, slow requests and writes are always kept in full. Record <code>sampled: true</code> so nobody mistakes a sampled dataset for a complete one.</p>
<h2 id="how-to-set-up-a-log-retention-policy">How to set up a log retention policy <a class="post-heading-anchor" href="#how-to-set-up-a-log-retention-policy" aria-hidden="true">#</a></h2>
<h3 id="step-1-split-logs-by-type">Step 1: split logs by type <a class="post-heading-anchor" href="#step-1-split-logs-by-type" aria-hidden="true">#</a></h3>
<p>Send request metadata, full request entries and security events to separate streams or indexes. Different data, different retention.</p>
<h3 id="step-2-redact-before-storage">Step 2: redact before storage <a class="post-heading-anchor" href="#step-2-redact-before-storage" aria-hidden="true">#</a></h3>
<p>Retention is easier to justify when logs hold no secrets. Mask credentials and personal fields at write time; <a href="/blog/redact-sensitive-data-logs">what not to log</a> has the list.</p>
<h3 id="step-3-automate-deletion">Step 3: automate deletion <a class="post-heading-anchor" href="#step-3-automate-deletion" aria-hidden="true">#</a></h3>
<p>Deletion that depends on someone remembering does not happen. If you archive logs to S3, a <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html" target="_blank" rel="noopener noreferrer">lifecycle rule</a> handles it:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">json</span></div><pre><code class="language-json hljs"><span class="hljs-punctuation">{</span>
  <span class="hljs-attr">"Rules"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span>
    <span class="hljs-punctuation">{</span> <span class="hljs-attr">"ID"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"full-requests"</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"Filter"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span> <span class="hljs-attr">"Prefix"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"requests-full/"</span> <span class="hljs-punctuation">}</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">"Status"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"Enabled"</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"Expiration"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span> <span class="hljs-attr">"Days"</span><span class="hljs-punctuation">:</span> <span class="hljs-number">14</span> <span class="hljs-punctuation">}</span> <span class="hljs-punctuation">}</span><span class="hljs-punctuation">,</span>
    <span class="hljs-punctuation">{</span> <span class="hljs-attr">"ID"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"metadata"</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"Filter"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span> <span class="hljs-attr">"Prefix"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"requests-meta/"</span> <span class="hljs-punctuation">}</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">"Status"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"Enabled"</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"Expiration"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span> <span class="hljs-attr">"Days"</span><span class="hljs-punctuation">:</span> <span class="hljs-number">90</span> <span class="hljs-punctuation">}</span> <span class="hljs-punctuation">}</span><span class="hljs-punctuation">,</span>
    <span class="hljs-punctuation">{</span> <span class="hljs-attr">"ID"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"security"</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"Filter"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span> <span class="hljs-attr">"Prefix"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"security/"</span> <span class="hljs-punctuation">}</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">"Status"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"Enabled"</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">"Transitions"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-punctuation">{</span> <span class="hljs-attr">"Days"</span><span class="hljs-punctuation">:</span> <span class="hljs-number">90</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"StorageClass"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"GLACIER_IR"</span> <span class="hljs-punctuation">}</span><span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span>
      <span class="hljs-attr">"Expiration"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span> <span class="hljs-attr">"Days"</span><span class="hljs-punctuation">:</span> <span class="hljs-number">400</span> <span class="hljs-punctuation">}</span> <span class="hljs-punctuation">}</span>
  <span class="hljs-punctuation">]</span>
<span class="hljs-punctuation">}</span>
</code></pre></div>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">bash</span></div><pre><code class="language-bash hljs">aws s3api put-bucket-lifecycle-configuration \
  --bucket my-api-logs --lifecycle-configuration file://lifecycle.json
</code></pre></div>
<p>If logs live in Postgres, partition by day and drop old partitions instead of running large <code>DELETE</code> statements.</p>
<h3 id="step-4-cut-duplicate-logging">Step 4: cut duplicate logging <a class="post-heading-anchor" href="#step-4-cut-duplicate-logging" aria-hidden="true">#</a></h3>
<p>Many APIs log the same request three times: the proxy access log, a framework logger and an application line. Keep one request log and make application logs carry the request ID. A clear <a href="/blog/nodejs-log-levels">logging policy with meaningful levels</a> also removes a lot of <code>debug</code> noise from production.</p>
<h2 id="log-retention-by-team-size">Log retention by team size <a class="post-heading-anchor" href="#log-retention-by-team-size" aria-hidden="true">#</a></h2>
<div class="post-table"><table>
<thead>
<tr>
<th></th>
<th>1-person team</th>
<th>10-person team</th>
</tr>
</thead>
<tbody>
<tr>
<td>Full request entries</td>
<td>7 days</td>
<td>14 days</td>
</tr>
<tr>
<td>Metadata</td>
<td>30 days</td>
<td>90 days</td>
</tr>
<tr>
<td>Security events</td>
<td>12 months, archived</td>
<td>12 months, 3 searchable</td>
</tr>
<tr>
<td>Sampling</td>
<td>Only if cost hurts</td>
<td>Sample fast 2xx GETs</td>
</tr>
<tr>
<td>Deletion</td>
<td>Lifecycle rule</td>
<td>Lifecycle rule plus a yearly review</td>
</tr>
</tbody>
</table></div>
<p>A solo developer mostly needs the last week in detail and a month of history for customer questions. A larger team has more people asking older questions, and more reason to write the policy down.</p>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<p>Write down three retention numbers (full entries, metadata, security events) and set a lifecycle rule that enforces them. A policy that deletes automatically beats a perfect policy on a wiki page.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="how-long-should-api-logs-be-retained">How long should API logs be retained? <a class="post-heading-anchor" href="#how-long-should-api-logs-be-retained" aria-hidden="true">#</a></h3>
<p>For most small teams, 7 to 14 days of full request logs, 30 to 90 days of metadata, and 12 months of security events. Adjust for contracts or regulations that apply to you, such as PCI DSS 10.5.1 for card data.</p>
<h3 id="how-can-i-reduce-logging-costs-without-losing-visibility">How can I reduce logging costs without losing visibility? <a class="post-heading-anchor" href="#how-can-i-reduce-logging-costs-without-losing-visibility" aria-hidden="true">#</a></h3>
<p>Keep a metadata line for every request, but store bodies only for errors, slow requests, writes and a sample of successful reads. Drop health check requests and duplicate log lines.</p>
<h3 id="does-gdpr-set-a-maximum-log-retention-period">Does GDPR set a maximum log retention period? <a class="post-heading-anchor" href="#does-gdpr-set-a-maximum-log-retention-period" aria-hidden="true">#</a></h3>
<p>No fixed number. It requires keeping personal data no longer than necessary for the purpose, so document why each retention period exists.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not build the sampling middleware, the redaction and the storage pipeline yourself, ApiTraffic captures every inbound request and response from one middleware.</p>
<ul>
<li><code>Authorization</code>, <code>Cookie</code> and <code>X-Api-Key</code> are masked by default, and <a href="https://www.apitraffic.io/docs/data/redactions">redaction rules</a> for other fields are set in the dashboard and pushed to servers without a deploy, so what you keep is safer to keep.</li>
<li>Requests are recorded <a href="https://www.apitraffic.io/docs/endpoint-handling">under the matched route pattern</a>, so per-endpoint counts and p50, p95 and p99 stay useful over weeks without storing anything extra yourself.</li>
<li><a href="https://www.apitraffic.io/docs/traffic/search">Searches</a> like <code>tag:userId == "1042" AND status_code &gt;= 400</code> answer the six-weeks-ago customer question directly.</li>
</ul>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260708-api-log-retention">apitraffic.io</a></p>
<p>Sources: <a href="https://gdpr-info.eu/art-5-gdpr/" target="_blank" rel="noopener noreferrer">GDPR Article 5, principles relating to processing of personal data</a>; <a href="https://learn.microsoft.com/en-us/entra/standards/pci-requirement-10" target="_blank" rel="noopener noreferrer">Microsoft Learn, PCI DSS requirement 10 (quotes 10.5.1)</a>; <a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lifecycle-mgmt.html" target="_blank" rel="noopener noreferrer">AWS, S3 Lifecycle configuration</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Node.js event loop lag: the metric that explains &quot;random&quot; slowness</title>
      <link>https://www.apitraffic.io/blog/nodejs-event-loop-lag</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/nodejs-event-loop-lag</guid>
      <pubDate>Mon, 22 Jun 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>Every endpoint slow at once, database fine, CPU looks idle: that is event loop lag. Here is how to measure it in Node with built-in tools and find the code that blocks.</description>
      <content:encoded><![CDATA[<p>A customer reports that your API is “sometimes slow”. You check: no single endpoint is slow, the database is fine, CPU on the box is at 30%. Then you notice <code>/health</code> also took 400 ms at the same moment. That pattern is Node.js event loop lag: one piece of synchronous work holding the only JavaScript thread, so every other request in the process waits behind it.</p>
<p>Event loop lag is the delay between when a callback should run and when it actually does. You measure it with <code>perf_hooks.monitorEventLoopDelay()</code>, and you fix it by finding the synchronous code that blocks the loop and moving it off the request path. Here is how to do each, with code.</p>
<h2 id="why-does-event-loop-lag-make-every-endpoint-slow">Why does event loop lag make every endpoint slow? <a class="post-heading-anchor" href="#why-does-event-loop-lag-make-every-endpoint-slow" aria-hidden="true">#</a></h2>
<p>Node runs your JavaScript on <a href="https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop" target="_blank" rel="noopener noreferrer">one thread</a>. While a handler spends 200 ms in <code>JSON.stringify</code> on a huge object, nothing else in that process runs: not other requests, not timers, not health checks. The slow request’s latency is honest. Everybody else’s latency is collateral.</p>
<p>The symptoms are distinctive:</p>
<ul>
<li>p99 rises on <strong>all</strong> endpoints at once, including trivial ones.</li>
<li>One core is pinned while total machine CPU looks modest (common on multi-core VPS and container hosts).</li>
<li>Database and outbound spans look normal, but there are unexplained gaps between them.</li>
<li>Health checks time out under load and the platform restarts a healthy process.</li>
</ul>
<p>That last one is why <a href="/blog/nodejs-health-check">liveness checks</a> should be cheap and have generous timeouts.</p>
<h2 id="how-to-measure-node-js-event-loop-lag">How to measure Node.js event loop lag <a class="post-heading-anchor" href="#how-to-measure-node-js-event-loop-lag" aria-hidden="true">#</a></h2>
<p>Node has <a href="https://nodejs.org/api/perf_hooks.html" target="_blank" rel="noopener noreferrer">two built-in tools</a>. <code>monitorEventLoopDelay()</code> samples how late a timer fires and records it in a histogram (in nanoseconds). <code>performance.eventLoopUtilization()</code> reports the fraction of time the loop was busy rather than idle.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// loop-metrics.js</span>
<span class="hljs-keyword">import</span> { monitorEventLoopDelay, performance } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:perf_hooks'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">startLoopMetrics</span>(<span class="hljs-params">log, intervalMs = <span class="hljs-number">10_000</span></span>) {
  <span class="hljs-keyword">const</span> h = <span class="hljs-title">monitorEventLoopDelay</span>({ <span class="hljs-attr">resolution</span>: <span class="hljs-number">20</span> });
  h.<span class="hljs-title">enable</span>();
  <span class="hljs-keyword">let</span> last = performance.<span class="hljs-title">eventLoopUtilization</span>();

  <span class="hljs-keyword">const</span> timer = <span class="hljs-built_in">setInterval</span>(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">const</span> elu = performance.<span class="hljs-title">eventLoopUtilization</span>(last);
    last = performance.<span class="hljs-title">eventLoopUtilization</span>();
    log.<span class="hljs-title">info</span>({
      <span class="hljs-attr">loop_delay_p50_ms</span>: +(h.<span class="hljs-title">percentile</span>(<span class="hljs-number">50</span>) / <span class="hljs-number">1e6</span>).<span class="hljs-title">toFixed</span>(<span class="hljs-number">1</span>),
      <span class="hljs-attr">loop_delay_p99_ms</span>: +(h.<span class="hljs-title">percentile</span>(<span class="hljs-number">99</span>) / <span class="hljs-number">1e6</span>).<span class="hljs-title">toFixed</span>(<span class="hljs-number">1</span>),
      <span class="hljs-attr">loop_delay_max_ms</span>: +(h.<span class="hljs-property">max</span> / <span class="hljs-number">1e6</span>).<span class="hljs-title">toFixed</span>(<span class="hljs-number">1</span>),
      <span class="hljs-attr">loop_utilization</span>: +elu.<span class="hljs-property">utilization</span>.<span class="hljs-title">toFixed</span>(<span class="hljs-number">3</span>),
    }, <span class="hljs-string">'event loop'</span>);
    h.<span class="hljs-title">reset</span>();
  }, intervalMs);
  timer.<span class="hljs-title">unref</span>();
}
</code></pre></div>
<p>Call it once at startup with your logger. Log these numbers or export them as gauges; either way, keep the per-interval max, because a single 800 ms block is exactly what this is meant to catch.</p>
<h2 id="how-to-read-the-numbers">How to read the numbers <a class="post-heading-anchor" href="#how-to-read-the-numbers" aria-hidden="true">#</a></h2>
<ul>
<li><strong>Delay p99 and max</strong> tell you how long requests waited for the loop. A max of 300 ms means some request got 300 ms added to it for no reason of its own.</li>
<li><strong>Utilization</strong> near 1.0 for sustained periods means the process is saturated: it is busy all the time, and more traffic will only queue. Low utilization with high max delay means occasional long blocks, which is the “random slowness” case.</li>
<li><strong>Compare against your own baseline.</strong> There is no universal threshold. Record a week of normal values, then alert or investigate when p99 delay moves well above it.</li>
</ul>
<p>Read the delay alongside request <a href="/blog/api-latency-percentiles">latency percentiles</a>: a p99 latency spike that lines up with a max loop delay spike points at blocking, not at the database.</p>
<h2 id="what-blocks-the-node-js-event-loop">What blocks the Node.js event loop? <a class="post-heading-anchor" href="#what-blocks-the-node-js-event-loop" aria-hidden="true">#</a></h2>
<p>The usual suspects in API code:</p>
<ol>
<li><strong>Large JSON.</strong> <code>JSON.parse</code> and <code>JSON.stringify</code> on multi-megabyte payloads, including logging big objects synchronously.</li>
<li><strong>Synchronous crypto.</strong> <code>pbkdf2Sync</code>, <code>scryptSync</code>, or a pure-JavaScript bcrypt on a login route.</li>
<li><strong>Regular expressions with catastrophic backtracking</strong>, especially on user input.</li>
<li><strong>Sync I/O on the request path:</strong> <code>readFileSync</code>, <code>zlib.gzipSync</code>, template compilation per request.</li>
<li><strong>Big in-memory loops:</strong> sorting or mapping tens of thousands of rows in JavaScript instead of in the database.</li>
<li><strong>Garbage collection.</strong> Large heaps mean longer pauses, and GC shows up as loop delay. If the heap grows steadily too, see <a href="/blog/nodejs-memory-leak-heap">debugging memory leaks with heap snapshots</a>.</li>
</ol>
<h2 id="how-to-find-the-code-that-blocks-the-loop">How to find the code that blocks the loop <a class="post-heading-anchor" href="#how-to-find-the-code-that-blocks-the-loop" aria-hidden="true">#</a></h2>
<h3 id="step-1-profile-under-realistic-load">Step 1: Profile under realistic load <a class="post-heading-anchor" href="#step-1-profile-under-realistic-load" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">bash</span></div><pre><code class="language-bash hljs">node --cpu-prof --cpu-prof-dir=./profiles server.js
<span class="hljs-comment"># generate load, then stop the process with Ctrl+C</span>
</code></pre></div>
<p>Open the <code>.cpuprofile</code> file in Chrome DevTools (Performance panel) and look for long, flat blocks of your own functions. Run it in staging with replayed or synthetic traffic; profiling production directly is possible but has overhead.</p>
<h3 id="step-2-time-suspicious-sections">Step 2: Time suspicious sections <a class="post-heading-anchor" href="#step-2-time-suspicious-sections" aria-hidden="true">#</a></h3>
<p>When you have a suspect, measure it directly:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">const</span> t0 = performance.<span class="hljs-title">now</span>();
<span class="hljs-keyword">const</span> body = <span class="hljs-title">JSON</span>.<span class="hljs-title">stringify</span>(report);
<span class="hljs-keyword">const</span> ms = performance.<span class="hljs-title">now</span>() - t0;
<span class="hljs-keyword">if</span> (ms &gt; <span class="hljs-number">50</span>) req.<span class="hljs-property">log</span>.<span class="hljs-title">warn</span>({ ms, <span class="hljs-attr">route</span>: req.<span class="hljs-property">routeOptions</span>?.<span class="hljs-property">url</span> }, <span class="hljs-string">'slow sync section'</span>);
</code></pre></div>
<h3 id="step-3-correlate-with-routes">Step 3: Correlate with routes <a class="post-heading-anchor" href="#step-3-correlate-with-routes" aria-hidden="true">#</a></h3>
<p>Log the loop max next to request logs with timestamps. The route whose slow requests consistently overlap with loop delay spikes is usually the culprit.</p>
<h2 id="how-to-fix-event-loop-blocking">How to fix event loop blocking <a class="post-heading-anchor" href="#how-to-fix-event-loop-blocking" aria-hidden="true">#</a></h2>
<ul>
<li>
<p><strong>Use async crypto.</strong> The callback and promise versions run in libuv’s thread pool:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> { pbkdf2 } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:crypto'</span>;
<span class="hljs-keyword">import</span> { promisify } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:util'</span>;
<span class="hljs-keyword">const</span> pbkdf2Async = <span class="hljs-title">promisify</span>(pbkdf2);
<span class="hljs-keyword">const</span> hash = <span class="hljs-keyword">await</span> <span class="hljs-title">pbkdf2Async</span>(password, salt, <span class="hljs-number">210_000</span>, <span class="hljs-number">32</span>, <span class="hljs-string">'sha256'</span>);
</code></pre></div>
</li>
<li>
<p><strong>Move CPU work to worker threads.</strong> For report generation, image work or heavy parsing, use <code>node:worker_threads</code> directly or a pool such as Piscina.</p>
</li>
<li>
<p><strong>Shrink the work.</strong> Paginate, select fewer columns, cap body sizes, and stream large responses instead of building one giant string.</p>
</li>
<li>
<p><strong>Fix regexes.</strong> Anchor them, avoid nested quantifiers, and cap input length before matching.</p>
</li>
</ul>
<h2 id="shedding-load-when-the-loop-is-saturated">Shedding load when the loop is saturated <a class="post-heading-anchor" href="#shedding-load-when-the-loop-is-saturated" aria-hidden="true">#</a></h2>
<p>Under a traffic spike, it can be better to return 503 quickly than to make every request slow. <a href="https://www.apitraffic.io/fastify">Fastify</a> has <a href="https://github.com/fastify/under-pressure" target="_blank" rel="noopener noreferrer"><code>@fastify/under-pressure</code></a>:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> underPressure <span class="hljs-keyword">from</span> <span class="hljs-string">'@fastify/under-pressure'</span>;

<span class="hljs-keyword">await</span> app.<span class="hljs-title">register</span>(underPressure, {
  <span class="hljs-attr">maxEventLoopDelay</span>: <span class="hljs-number">1000</span>,       <span class="hljs-comment">// ms</span>
  <span class="hljs-attr">maxEventLoopUtilization</span>: <span class="hljs-number">0.98</span>,
  <span class="hljs-attr">retryAfter</span>: <span class="hljs-number">10</span>,
});
</code></pre></div>
<p>In Express, you can build the same check on the histogram above, but keep the threshold high: shedding healthy traffic because of one blip causes its own incidents.</p>
<p>A solo developer should add the metrics function and look at the numbers once a week. A team of ten should export them as metrics, put them on the same dashboard as p99 latency, and check them in every performance investigation.</p>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<ul>
<li>Add <code>startLoopMetrics()</code> to every service today.</li>
<li>When p99 latency jumps everywhere at once, check loop max delay before the database.</li>
<li>Replace any <code>*Sync</code> crypto or file call on a request path.</li>
<li>Profile one busy endpoint with <code>--cpu-prof</code> this month.</li>
</ul>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="what-is-a-normal-event-loop-delay-in-node-js">What is a normal event loop delay in Node.js? <a class="post-heading-anchor" href="#what-is-a-normal-event-loop-delay-in-node-js" aria-hidden="true">#</a></h3>
<p>It depends on the timer resolution and your workload, so measure your own baseline. What matters is change: a max delay of hundreds of milliseconds means some requests waited that long for the loop.</p>
<h3 id="does-event-loop-lag-show-up-as-high-cpu">Does event loop lag show up as high CPU? <a class="post-heading-anchor" href="#does-event-loop-lag-show-up-as-high-cpu" aria-hidden="true">#</a></h3>
<p>Usually as one busy core, not high total CPU. On a multi-core host the machine can look mostly idle while the Node process is saturated.</p>
<h3 id="will-adding-more-instances-fix-event-loop-lag">Will adding more instances fix event loop lag? <a class="post-heading-anchor" href="#will-adding-more-instances-fix-event-loop-lag" aria-hidden="true">#</a></h3>
<p>It spreads load, so fewer requests queue behind each block, but each blocked request is still slow. Fix the blocking code; scale for throughput.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not build span tracing to tell blocking from slow dependencies, ApiTraffic <a href="https://www.apitraffic.io/docs/apm/overview">records database, cache, queue and outbound spans inside each request</a> via <a href="https://www.apitraffic.io/docs/apm/setup">OpenTelemetry</a>.</p>
<ul>
<li>An endpoint’s <a href="https://www.apitraffic.io/docs/apm/analytics">Time Breakdown</a> shows where its time goes. When most of it is not in queries or outbound calls, look at your own code and the loop.</li>
<li>The request waterfall shows gaps between spans on a slow request; long gaps with no span are where synchronous work ran.</li>
<li><code>span()</code> adds a custom timing around a suspect section, such as a big <code>JSON.stringify</code>, so it appears on the waterfall.</li>
</ul>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260622-nodejs-event-loop-lag">apitraffic.io</a></p>
<p>Sources: <a href="https://nodejs.org/api/perf_hooks.html" target="_blank" rel="noopener noreferrer">Node.js docs: perf_hooks (monitorEventLoopDelay, eventLoopUtilization)</a>; <a href="https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop" target="_blank" rel="noopener noreferrer">Node.js guide: Don’t Block the Event Loop (or the Worker Pool)</a>; <a href="https://github.com/fastify/under-pressure" target="_blank" rel="noopener noreferrer">@fastify/under-pressure on GitHub</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>CORS misconfiguration in Express and Fastify: how to find it and test for it</title>
      <link>https://www.apitraffic.io/blog/cors-misconfiguration-express</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/cors-misconfiguration-express</guid>
      <pubDate>Fri, 12 Jun 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>One line added to silence a frontend error, cors({ origin: true, credentials: true }), lets any website read your users&apos; data. Find CORS misconfiguration in Express and Fastify APIs, fix it, and test it in CI.</description>
      <content:encoded><![CDATA[<p>Someone opens a security report against your API: “Access-Control-Allow-Origin reflects arbitrary origins with credentials.” It is usually one line, <code>cors({ origin: true, credentials: true })</code>, added months ago to make a frontend error go away. A CORS misconfiguration like this lets any website a logged-in user visits read that user’s data from your API.</p>
<p>The direct answer: if your API uses cookies or other browser credentials, allow only an exact list of origins, never reflect the request’s <code>Origin</code>, never allow <code>null</code>, and never match origins with an unanchored regex. If your API uses bearer tokens only, you usually do not need credentials in CORS at all. Then test it with three <code>curl</code> commands, shown below.</p>
<h2 id="what-cors-actually-protects-and-what-it-does-not">What CORS actually protects (and what it does not) <a class="post-heading-anchor" href="#what-cors-actually-protects-and-what-it-does-not" aria-hidden="true">#</a></h2>
<p>Browsers stop a page on <code>https://evil.test</code> from reading responses from <code>https://api.example.com</code> unless the API says otherwise with <code>Access-Control-Allow-Origin</code>. When the response also includes <code>Access-Control-Allow-Credentials: true</code>, the browser will send cookies with the request and let the page read the result.</p>
<p>Three facts about CORS that cause most mistakes:</p>
<ol>
<li><strong>CORS is enforced by the browser, not your server.</strong> <code>curl</code>, scripts and other servers ignore it. It is not authentication or authorization.</li>
<li><strong>CORS controls reading, not sending.</strong> For simple requests, the browser sends the request and your handler runs; it just hides the response from the page. A state-changing <code>POST</code> with a form content type still executes.</li>
<li><strong>The dangerous combination is a trusted-looking origin plus credentials.</strong> <code>Access-Control-Allow-Origin: *</code> with <code>Access-Control-Allow-Credentials: true</code> is <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS" target="_blank" rel="noopener noreferrer">rejected by browsers</a>, so wildcard plus credentials is broken rather than exploitable. Reflecting the origin is what makes it exploitable.</li>
</ol>
<h2 id="four-cors-misconfigurations-tested">Four CORS misconfigurations, tested <a class="post-heading-anchor" href="#four-cors-misconfigurations-tested" aria-hidden="true">#</a></h2>
<p>Here is what the <code>cors</code> middleware for Express returns for four common configurations when probed with different <code>Origin</code> headers (output trimmed to the two CORS headers).</p>
<h3 id="1-reflecting-any-origin">1. Reflecting any origin <a class="post-heading-anchor" href="#1-reflecting-any-origin" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">app.<span class="hljs-title">use</span>(<span class="hljs-title">cors</span>({ <span class="hljs-attr">origin</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">credentials</span>: <span class="hljs-literal">true</span> }));
</code></pre></div>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">text</span></div><pre><code class="language-text hljs">https://evil.test                    ACAO=https://evil.test ACAC=true
https://app.example.com.evil.test    ACAO=https://app.example.com.evil.test ACAC=true
null                                 ACAO=null ACAC=true
</code></pre></div>
<p><code>origin: true</code> means “echo back whatever Origin was sent”. With credentials, every website on the internet can make authenticated reads as your logged-in users. <code>@fastify/cors</code> with <code>origin: true</code> behaves the same way. This is the one to fix first.</p>
<h3 id="2-an-unanchored-regex">2. An unanchored regex <a class="post-heading-anchor" href="#2-an-unanchored-regex" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">app.<span class="hljs-title">use</span>(<span class="hljs-title">cors</span>({ <span class="hljs-attr">origin</span>: <span class="hljs-regexp">/https:\/\/app\.example\.com/</span>, <span class="hljs-attr">credentials</span>: <span class="hljs-literal">true</span> }));
</code></pre></div>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">text</span></div><pre><code class="language-text hljs">https://evil.test                    ACAO=-
https://app.example.com.evil.test    ACAO=https://app.example.com.evil.test ACAC=true
</code></pre></div>
<p>The regex matches anywhere in the string, so an attacker registers <code>app.example.com.evil.test</code>. If you need a pattern, anchor it: <code>/^https:\/\/([a-z0-9-]+\.)?example\.com$/</code>. Better still, use a list.</p>
<h3 id="3-allowing-the-null-origin">3. Allowing the null origin <a class="post-heading-anchor" href="#3-allowing-the-null-origin" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">app.<span class="hljs-title">use</span>(<span class="hljs-title">cors</span>({ <span class="hljs-attr">origin</span>: [<span class="hljs-string">'https://app.example.com'</span>, <span class="hljs-string">'null'</span>], <span class="hljs-attr">credentials</span>: <span class="hljs-literal">true</span> }));
</code></pre></div>
<p>Developers add <code>'null'</code> because local files or a sandboxed iframe sent it during testing. Sandboxed iframes and some redirects send <code>Origin: null</code>, and an attacker can create those, so allowing <code>null</code> is close to allowing everyone.</p>
<h3 id="4-wildcard-with-credentials">4. Wildcard with credentials <a class="post-heading-anchor" href="#4-wildcard-with-credentials" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">app.<span class="hljs-title">use</span>(<span class="hljs-title">cors</span>({ <span class="hljs-attr">origin</span>: <span class="hljs-string">'*'</span>, <span class="hljs-attr">credentials</span>: <span class="hljs-literal">true</span> }));
</code></pre></div>
<p>This returns <code>ACAO=*</code> with <code>ACAC=true</code>. Browsers refuse it, so your frontend breaks, which is often what leads someone to “fix” it with <code>origin: true</code>.</p>
<h2 id="how-to-configure-cors-safely-in-express">How to configure CORS safely in Express <a class="post-heading-anchor" href="#how-to-configure-cors-safely-in-express" aria-hidden="true">#</a></h2>
<p>Use an exact allowlist from configuration, and let requests with no <code>Origin</code> (same-origin, server-to-server, <code>curl</code>) through without CORS headers:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> cors <span class="hljs-keyword">from</span> <span class="hljs-string">'cors'</span>;

<span class="hljs-keyword">const</span> allowedOrigins = <span class="hljs-keyword">new</span> <span class="hljs-title">Set</span>(
  (process.<span class="hljs-property">env</span>.<span class="hljs-property">CORS_ORIGINS</span> ?? <span class="hljs-string">'https://app.example.com'</span>).<span class="hljs-title">split</span>(<span class="hljs-string">','</span>)
);

app.<span class="hljs-title">use</span>(
  <span class="hljs-title">cors</span>({
    <span class="hljs-attr">origin</span>: <span class="hljs-function">(<span class="hljs-params">origin, callback</span>) =&gt;</span> <span class="hljs-title">callback</span>(<span class="hljs-literal">null</span>, !origin || allowedOrigins.<span class="hljs-title">has</span>(origin)),
    <span class="hljs-attr">credentials</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">methods</span>: [<span class="hljs-string">'GET'</span>, <span class="hljs-string">'POST'</span>, <span class="hljs-string">'PUT'</span>, <span class="hljs-string">'PATCH'</span>, <span class="hljs-string">'DELETE'</span>],
    <span class="hljs-attr">maxAge</span>: <span class="hljs-number">600</span>,
  })
);
</code></pre></div>
<p>The <a href="https://github.com/expressjs/cors" target="_blank" rel="noopener noreferrer"><code>cors</code> package</a> adds <code>Vary: Origin</code> when the allowed origin varies, which stops a CDN or proxy from caching one origin’s CORS headers and serving them to another. If you set CORS headers by hand, add <code>Vary: Origin</code> yourself.</p>
<p>Preview deployments on Render, Vercel or Netlify are where allowlists tend to get loosened. Prefer adding preview origins in the preview environment’s config over a wildcard pattern in production.</p>
<h2 id="how-to-configure-cors-safely-in-fastify">How to configure CORS safely in Fastify <a class="post-heading-anchor" href="#how-to-configure-cors-safely-in-fastify" aria-hidden="true">#</a></h2>
<p><a href="https://github.com/fastify/fastify-cors" target="_blank" rel="noopener noreferrer"><code>@fastify/cors</code></a> accepts the same idea:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> <span class="hljs-title">Fastify</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'fastify'</span>;
<span class="hljs-keyword">import</span> cors <span class="hljs-keyword">from</span> <span class="hljs-string">'@fastify/cors'</span>;

<span class="hljs-keyword">const</span> app = <span class="hljs-title">Fastify</span>();
<span class="hljs-keyword">const</span> allowedOrigins = <span class="hljs-keyword">new</span> <span class="hljs-title">Set</span>(process.<span class="hljs-property">env</span>.<span class="hljs-property">CORS_ORIGINS</span>.<span class="hljs-title">split</span>(<span class="hljs-string">','</span>));

<span class="hljs-keyword">await</span> app.<span class="hljs-title">register</span>(cors, {
  <span class="hljs-attr">origin</span>: <span class="hljs-function">(<span class="hljs-params">origin, cb</span>) =&gt;</span> <span class="hljs-title">cb</span>(<span class="hljs-literal">null</span>, !origin || allowedOrigins.<span class="hljs-title">has</span>(origin)),
  <span class="hljs-attr">credentials</span>: <span class="hljs-literal">true</span>,
});
</code></pre></div>
<p>If you add auth in Fastify hooks (see <a href="/blog/fastify-hooks-logging-auth">Fastify hooks for logging and auth</a>), remember that preflight <code>OPTIONS</code> requests carry no credentials. Make sure the CORS plugin answers them before your auth hook rejects them with a <code>401</code>.</p>
<h2 id="how-to-test-your-api-for-cors-misconfiguration">How to test your API for CORS misconfiguration <a class="post-heading-anchor" href="#how-to-test-your-api-for-cors-misconfiguration" aria-hidden="true">#</a></h2>
<h3 id="step-1-probe-with-curl">Step 1: probe with curl <a class="post-heading-anchor" href="#step-1-probe-with-curl" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">bash</span></div><pre><code class="language-bash hljs">API=https://api.example.com/v1/me
<span class="hljs-keyword">for</span> origin <span class="hljs-keyword">in</span> https://evil.test https://app.example.com.evil.test null; <span class="hljs-keyword">do</span>
  <span class="hljs-built_in">echo</span> <span class="hljs-string">"== <span class="hljs-variable">$origin</span>"</span>
  curl -s -o /dev/null -D - -H <span class="hljs-string">"Origin: <span class="hljs-variable">$origin</span>"</span> <span class="hljs-string">"<span class="hljs-variable">$API</span>"</span> \
    | grep -i <span class="hljs-string">'^access-control-allow-'</span>
<span class="hljs-keyword">done</span>
</code></pre></div>
<p>Any output line containing the probe origin together with <code>access-control-allow-credentials: true</code> is a finding.</p>
<h3 id="step-2-probe-the-preflight">Step 2: probe the preflight <a class="post-heading-anchor" href="#step-2-probe-the-preflight" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">bash</span></div><pre><code class="language-bash hljs">curl -s -o /dev/null -D - -X OPTIONS <span class="hljs-string">"<span class="hljs-variable">$API</span>"</span> \
  -H <span class="hljs-string">"Origin: https://evil.test"</span> \
  -H <span class="hljs-string">"Access-Control-Request-Method: DELETE"</span> | grep -i <span class="hljs-string">'^access-control-'</span>
</code></pre></div>
<h3 id="step-3-add-a-regression-test">Step 3: add a regression test <a class="post-heading-anchor" href="#step-3-add-a-regression-test" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> { test } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:test'</span>;
<span class="hljs-keyword">import</span> assert <span class="hljs-keyword">from</span> <span class="hljs-string">'node:assert/strict'</span>;

<span class="hljs-title">test</span>(<span class="hljs-string">'CORS does not trust arbitrary origins'</span>, <span class="hljs-title">async</span> () =&gt; {
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> origin <span class="hljs-keyword">of</span> [<span class="hljs-string">'https://evil.test'</span>, <span class="hljs-string">'https://app.example.com.evil.test'</span>, <span class="hljs-string">'null'</span>]) {
    <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title">fetch</span>(<span class="hljs-string">`<span class="hljs-subst">${process.env.API_URL}</span>/v1/me`</span>, { <span class="hljs-attr">headers</span>: { <span class="hljs-title">Origin</span>: origin } });
    assert.<span class="hljs-title">notEqual</span>(res.<span class="hljs-property">headers</span>.<span class="hljs-title">get</span>(<span class="hljs-string">'access-control-allow-origin'</span>), origin);
  }
});
</code></pre></div>
<h2 id="cors-misconfiguration-checklist">CORS misconfiguration checklist <a class="post-heading-anchor" href="#cors-misconfiguration-checklist" aria-hidden="true">#</a></h2>
<ul>
<li>[ ] No <code>origin: true</code> (or hand-written reflection) together with <code>credentials: true</code>.</li>
<li>[ ] Origins are an exact list from config; any regex is anchored with <code>^</code> and <code>$</code>.</li>
<li>[ ] <code>null</code> is not in the list.</li>
<li>[ ] <code>Vary: Origin</code> is present when the allowed origin varies.</li>
<li>[ ] Bearer-token-only APIs do not enable credentials.</li>
<li>[ ] A test like the one above runs in CI.</li>
</ul>
<p>A solo developer can do all six in an afternoon. On a team of ten, the useful extra step is making the allowlist a config value owned by one person, so a frontend developer fighting a CORS error opens a PR against config rather than loosening the code. And remember that tight CORS does not replace per-object authorization checks; see <a href="/blog/bola-vulnerability-node-api">BOLA and IDOR</a>.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="is-access-control-allow-origin-insecure">Is Access-Control-Allow-Origin: * insecure? <a class="post-heading-anchor" href="#is-access-control-allow-origin-insecure" aria-hidden="true">#</a></h3>
<p>Not on its own. For a public API that does not use cookies, <code>*</code> is fine, and browsers refuse to combine it with credentials. The risk is reflecting specific origins with credentials enabled.</p>
<h3 id="does-cors-protect-my-api-from-attackers">Does CORS protect my API from attackers? <a class="post-heading-anchor" href="#does-cors-protect-my-api-from-attackers" aria-hidden="true">#</a></h3>
<p>No. CORS only controls which web pages a browser lets read your responses. Attackers using <code>curl</code> or scripts are unaffected, so authentication, authorization and rate limiting still matter.</p>
<h3 id="why-do-i-get-a-cors-error-even-though-my-server-sends-the-headers">Why do I get a CORS error even though my server sends the headers? <a class="post-heading-anchor" href="#why-do-i-get-a-cors-error-even-though-my-server-sends-the-headers" aria-hidden="true">#</a></h3>
<p>Usually the preflight fails: an auth middleware returns <code>401</code> to the <code>OPTIONS</code> request, or the requested method or header is not allowed. Check the <code>OPTIONS</code> response in the browser’s network tab first.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not guess which origins actually call your API before tightening CORS, ApiTraffic records every request with its headers from <a href="https://www.apitraffic.io/docs/integrations/express">one middleware</a>, so you can see the <code>Origin</code> values hitting each endpoint before you change the allowlist. Credential headers like <code>Authorization</code> and <code>Cookie</code> are <a href="https://www.apitraffic.io/docs/data/redactions">masked by default</a>.</p>
<p>The firewall’s <a href="https://www.apitraffic.io/docs/waf/policies">access lists</a> can also deny by user agent, path, IP or CIDR, and <a href="https://www.apitraffic.io/docs/waf/overview">monitor mode</a> shows what a rule would block before it blocks anything.</p>
<p>Start free and see which origins call your API: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260612-cors-misconfiguration-express">apitraffic.io</a></p>
<p>Sources: <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS" target="_blank" rel="noopener noreferrer">MDN: Cross-Origin Resource Sharing (CORS)</a>; <a href="https://fetch.spec.whatwg.org/#http-cors-protocol" target="_blank" rel="noopener noreferrer">WHATWG Fetch Standard, CORS protocol</a>; <a href="https://portswigger.net/web-security/cors" target="_blank" rel="noopener noreferrer">PortSwigger Web Security Academy: CORS</a>; <a href="https://github.com/expressjs/cors" target="_blank" rel="noopener noreferrer">cors middleware README</a>; <a href="https://github.com/fastify/fastify-cors" target="_blank" rel="noopener noreferrer">@fastify/cors README</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Idempotency keys in Node.js: safe retries for POST endpoints without double charges</title>
      <link>https://www.apitraffic.io/blog/idempotency-keys-node</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/idempotency-keys-node</guid>
      <pubDate>Wed, 03 Jun 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>A timeout, an automatic retry, and a customer charged twice. Idempotency keys make POST retries safe: here is a working Express and Postgres implementation, the status codes to return, and how long to keep keys.</description>
      <content:encoded><![CDATA[<p>A customer on a flaky mobile connection taps “Pay”, the request times out on their side, the app retries, and your API charges them twice. Your code did nothing wrong on either request. The problem is that <code>POST</code> is not idempotent, and the client had no way to say “this is the same attempt as before”.</p>
<p>An idempotency key fixes that. The client generates a unique key (a UUID) once per logical operation and sends it in an <code>Idempotency-Key</code> header on every attempt. The server stores the key with the result of the first attempt; any retry with the same key gets the stored response instead of running the operation again. Below is a working <a href="https://www.apitraffic.io/express">Express</a> implementation on Postgres, the edge cases that bite (concurrent retries, changed payloads, crashes mid-request), and how long to keep keys.</p>
<h2 id="why-post-needs-an-idempotency-key-and-put-usually-does-not">Why POST needs an idempotency key and PUT usually does not <a class="post-heading-anchor" href="#why-post-needs-an-idempotency-key-and-put-usually-does-not" aria-hidden="true">#</a></h2>
<p>HTTP defines <code>GET</code>, <code>HEAD</code>, <code>PUT</code> and <code>DELETE</code> as idempotent: sending the same request twice has the same effect as sending it once (<a href="https://www.rfc-editor.org/rfc/rfc9110#section-9.2.2" target="_blank" rel="noopener noreferrer">RFC 9110, section 9.2.2</a>). <code>POST</code> and <code>PATCH</code> are not. “Create a payment” twice creates two payments.</p>
<p>Clients retry for good reasons: timeouts, dropped connections, a <code>502</code> from a load balancer during your deploy. If you have set sensible client timeouts (see <a href="/blog/nodejs-fetch-timeout">timeouts everywhere in Node.js</a>), retries will happen, and the server must be the one that makes them safe.</p>
<h2 id="how-the-idempotency-key-header-works">How the Idempotency-Key header works <a class="post-heading-anchor" href="#how-the-idempotency-key-header-works" aria-hidden="true">#</a></h2>
<p>The IETF httpapi working group has a <a href="https://www.ietf.org/archive/id/draft-ietf-httpapi-idempotency-key-header-07.html" target="_blank" rel="noopener noreferrer">draft standard for this header</a> (revision 07, October 2025). It is not an RFC yet, but its rules match what payment APIs such as Stripe have done for years:</p>
<div class="post-table"><table>
<thead>
<tr>
<th>Situation</th>
<th>Server response</th>
</tr>
</thead>
<tbody>
<tr>
<td>First request with a new key</td>
<td>Run the operation, store status and body, return it</td>
</tr>
<tr>
<td>Retry with the same key and same payload, finished</td>
<td>Return the stored response, do not run again</td>
</tr>
<tr>
<td>Same key, request still running</td>
<td><code>409 Conflict</code>, client may retry later</td>
</tr>
<tr>
<td>Same key, different payload</td>
<td><code>422 Unprocessable Content</code>, client bug</td>
</tr>
<tr>
<td>Key missing on an endpoint that requires it</td>
<td><code>400 Bad Request</code></td>
</tr>
</tbody>
</table></div>
<p>The draft recommends a UUID or similar random value as the key, and says servers should publish how long keys are kept. <a href="https://docs.stripe.com/api/idempotent_requests" target="_blank" rel="noopener noreferrer">Stripe’s documentation</a> says keys can be pruned once they are at least 24 hours old and that the stored response is returned even when it was a <code>500</code>.</p>
<h2 id="implementing-idempotency-keys-in-express-with-postgres">Implementing idempotency keys in Express with Postgres <a class="post-heading-anchor" href="#implementing-idempotency-keys-in-express-with-postgres" aria-hidden="true">#</a></h2>
<h3 id="step-1-a-table-with-a-unique-key-per-caller">Step 1: a table with a unique key per caller <a class="post-heading-anchor" href="#step-1-a-table-with-a-unique-key-per-caller" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">sql</span></div><pre><code class="language-sql hljs"><span class="hljs-keyword">CREATE TABLE</span> idempotency_keys (
  account_id    <span class="hljs-type">bigint</span>      <span class="hljs-keyword">NOT NULL</span>,
  key           text        <span class="hljs-keyword">NOT NULL</span>,
  fingerprint   text        <span class="hljs-keyword">NOT NULL</span>,
  status        text        <span class="hljs-keyword">NOT NULL</span> <span class="hljs-keyword">DEFAULT</span> <span class="hljs-string">'running'</span>,  <span class="hljs-comment">-- running | done</span>
  response_code <span class="hljs-type">int</span>,
  response_body jsonb,
  created_at    timestamptz <span class="hljs-keyword">NOT NULL</span> <span class="hljs-keyword">DEFAULT</span> now(),
  <span class="hljs-keyword">PRIMARY KEY</span> (account_id, key)
);
<span class="hljs-keyword">CREATE</span> INDEX <span class="hljs-keyword">ON</span> idempotency_keys (created_at);
</code></pre></div>
<p>Scope keys to the caller. Without <code>account_id</code> in the primary key, two customers who happen to send the same key collide, and one could read the other’s stored response.</p>
<h3 id="step-2-the-middleware">Step 2: the middleware <a class="post-heading-anchor" href="#step-2-the-middleware" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// idempotency.js</span>
<span class="hljs-keyword">import</span> { createHash } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:crypto'</span>;
<span class="hljs-keyword">import</span> { pool } <span class="hljs-keyword">from</span> <span class="hljs-string">'./db.js'</span>;

<span class="hljs-keyword">const</span> <span class="hljs-variable">KEY_RE</span> = <span class="hljs-regexp">/^[A-Za-z0-9_-]{8,255}$/</span>;
<span class="hljs-keyword">const</span> <span class="hljs-variable">STALE_MS</span> = <span class="hljs-number">60_000</span>; <span class="hljs-comment">// a "running" row older than this is treated as abandoned</span>

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">idempotent</span>(<span class="hljs-params">{ required = <span class="hljs-literal">true</span> } = {}</span>) {
  <span class="hljs-keyword">return</span> <span class="hljs-title">async</span> (req, res, next) =&gt; {
    <span class="hljs-keyword">const</span> key = req.<span class="hljs-title">get</span>(<span class="hljs-string">'Idempotency-Key'</span>)?.<span class="hljs-title">replace</span>(<span class="hljs-regexp">/^"|"$/g</span>, <span class="hljs-string">''</span>);
    <span class="hljs-keyword">if</span> (!key) {
      <span class="hljs-keyword">return</span> required ? res.<span class="hljs-title">status</span>(<span class="hljs-number">400</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'Idempotency-Key header required'</span> }) : <span class="hljs-title">next</span>();
    }
    <span class="hljs-keyword">if</span> (!<span class="hljs-variable">KEY_RE</span>.<span class="hljs-title">test</span>(key)) <span class="hljs-keyword">return</span> res.<span class="hljs-title">status</span>(<span class="hljs-number">400</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'invalid Idempotency-Key'</span> });

    <span class="hljs-keyword">const</span> accountId = req.<span class="hljs-property">user</span>.<span class="hljs-property">accountId</span>;
    <span class="hljs-keyword">const</span> fingerprint = <span class="hljs-title">createHash</span>(<span class="hljs-string">'sha256'</span>)
      .<span class="hljs-title">update</span>(<span class="hljs-string">`<span class="hljs-subst">${req.method}</span> <span class="hljs-subst">${req.baseUrl}</span><span class="hljs-subst">${req.path}</span>\n<span class="hljs-subst">${<span class="hljs-built_in">JSON</span>.stringify(req.body ?? <span class="hljs-literal">null</span>)}</span>`</span>)
      .<span class="hljs-title">digest</span>(<span class="hljs-string">'hex'</span>);

    <span class="hljs-keyword">const</span> inserted = <span class="hljs-keyword">await</span> pool.<span class="hljs-title">query</span>(
      <span class="hljs-string">`INSERT INTO idempotency_keys (account_id, key, fingerprint)
       VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING key`</span>,
      [accountId, key, fingerprint],
    );

    <span class="hljs-keyword">if</span> (inserted.<span class="hljs-property">rowCount</span> === <span class="hljs-number">0</span>) {
      <span class="hljs-keyword">const</span> { <span class="hljs-attr">rows</span>: [row] } = <span class="hljs-keyword">await</span> pool.<span class="hljs-title">query</span>(
        <span class="hljs-string">'SELECT * FROM idempotency_keys WHERE account_id = $1 AND key = $2'</span>,
        [accountId, key],
      );
      <span class="hljs-keyword">if</span> (row.<span class="hljs-property">fingerprint</span> !== fingerprint) {
        <span class="hljs-keyword">return</span> res.<span class="hljs-title">status</span>(<span class="hljs-number">422</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'Idempotency-Key reused with a different request'</span> });
      }
      <span class="hljs-keyword">if</span> (row.<span class="hljs-property">status</span> === <span class="hljs-string">'done'</span>) {
        res.<span class="hljs-title">set</span>(<span class="hljs-string">'Idempotent-Replayed'</span>, <span class="hljs-string">'true'</span>);
        <span class="hljs-keyword">return</span> res.<span class="hljs-title">status</span>(row.<span class="hljs-property">response_code</span>).<span class="hljs-title">json</span>(row.<span class="hljs-property">response_body</span>);
      }
      <span class="hljs-keyword">if</span> (<span class="hljs-title">Date</span>.<span class="hljs-title">now</span>() - row.<span class="hljs-property">created_at</span>.<span class="hljs-title">getTime</span>() &lt; <span class="hljs-variable">STALE_MS</span>) {
        <span class="hljs-keyword">return</span> res.<span class="hljs-title">status</span>(<span class="hljs-number">409</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'request with this Idempotency-Key is in progress'</span> });
      }
      <span class="hljs-comment">// Abandoned by a crashed process: take it over.</span>
      <span class="hljs-keyword">await</span> pool.<span class="hljs-title">query</span>(
        <span class="hljs-string">'UPDATE idempotency_keys SET created_at = now() WHERE account_id = $1 AND key = $2'</span>,
        [accountId, key],
      );
    }

    <span class="hljs-keyword">const</span> json = res.<span class="hljs-property">json</span>.<span class="hljs-title">bind</span>(res);
    res.<span class="hljs-property">json</span> = <span class="hljs-function">(<span class="hljs-params">body</span>) =&gt;</span> {
      <span class="hljs-keyword">const</span> done = res.<span class="hljs-property">statusCode</span> &lt; <span class="hljs-number">500</span>
        ? pool.<span class="hljs-title">query</span>(
            <span class="hljs-string">`UPDATE idempotency_keys SET status = 'done', response_code = $3, response_body = $4
             WHERE account_id = $1 AND key = $2`</span>,
            [accountId, key, res.<span class="hljs-property">statusCode</span>, body],
          )
        : pool.<span class="hljs-title">query</span>(<span class="hljs-string">'DELETE FROM idempotency_keys WHERE account_id = $1 AND key = $2'</span>, [accountId, key]);
      done.<span class="hljs-title">catch</span>(<span class="hljs-function">(<span class="hljs-params">err</span>) =&gt;</span> req.<span class="hljs-property">log</span>?.<span class="hljs-title">error</span>({ err }, <span class="hljs-string">'idempotency write failed'</span>));
      <span class="hljs-keyword">return</span> <span class="hljs-title">json</span>(body);
    };
    <span class="hljs-title">next</span>();
  };
}
</code></pre></div>
<h3 id="step-3-use-it-on-the-routes-that-move-money-or-create-things">Step 3: use it on the routes that move money or create things <a class="post-heading-anchor" href="#step-3-use-it-on-the-routes-that-move-money-or-create-things" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">app.<span class="hljs-title">post</span>(<span class="hljs-string">'/v1/payments'</span>, authenticate, <span class="hljs-title">idempotent</span>(), <span class="hljs-title">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> payment = <span class="hljs-keyword">await</span> <span class="hljs-title">createPayment</span>(req.<span class="hljs-property">user</span>.<span class="hljs-property">accountId</span>, req.<span class="hljs-property">body</span>);
  res.<span class="hljs-title">status</span>(<span class="hljs-number">201</span>).<span class="hljs-title">json</span>(payment);
});
</code></pre></div>
<p><a href="/blog/express-5-migration">Express 5 forwards rejected promises</a> from async handlers to your error middleware, so a thrown error here reaches it, and the error handler’s <code>res.json</code> call deletes the key so the client can retry.</p>
<h2 id="the-decisions-you-have-to-make">The decisions you have to make <a class="post-heading-anchor" href="#the-decisions-you-have-to-make" aria-hidden="true">#</a></h2>
<h3 id="store-5xx-responses-or-let-them-retry">Store 5xx responses or let them retry? <a class="post-heading-anchor" href="#store-5xx-responses-or-let-them-retry" aria-hidden="true">#</a></h3>
<p>Stripe stores the first response, including a <code>500</code>. That is the safest choice when a failure might have happened after the side effect (the charge went through, then the database write failed). The middleware above takes the other path: it deletes the key on a <code>5xx</code> so the client can retry. That is only safe if your handler is transactional, meaning a 5xx guarantees nothing was committed. If the handler calls an external payment provider, pass the same key through to the provider so their side is idempotent too.</p>
<h3 id="how-long-to-keep-keys">How long to keep keys <a class="post-heading-anchor" href="#how-long-to-keep-keys" aria-hidden="true">#</a></h3>
<p>Keep them at least as long as clients retry, plus a margin. 24 hours is a common choice. Delete old rows on a schedule:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">sql</span></div><pre><code class="language-sql hljs"><span class="hljs-keyword">DELETE</span> <span class="hljs-keyword">FROM</span> idempotency_keys <span class="hljs-keyword">WHERE</span> created_at <span class="hljs-operator">&lt;</span> now() <span class="hljs-operator">-</span> <span class="hljs-type">interval</span> <span class="hljs-string">'24 hours'</span>;
</code></pre></div>
<p>Document the window in your API docs, as the draft asks.</p>
<h3 id="redis-instead-of-postgres">Redis instead of Postgres <a class="post-heading-anchor" href="#redis-instead-of-postgres" aria-hidden="true">#</a></h3>
<p>If you already run Redis, <code>SET key value NX EX 86400</code> gives you the same “first writer wins” step. Keep Postgres if the operation itself writes to Postgres: storing the key in the same transaction as the payment row removes a whole class of crash-in-between bugs.</p>
<h2 id="the-client-side">The client side <a class="post-heading-anchor" href="#the-client-side" aria-hidden="true">#</a></h2>
<p>The key must be created once per user intent, not once per HTTP attempt:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">const</span> key = crypto.<span class="hljs-title">randomUUID</span>();   <span class="hljs-comment">// when the user clicks Pay</span>
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> attempt = <span class="hljs-number">1</span>; attempt &lt;= <span class="hljs-number">3</span>; attempt++) {
  <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title">fetch</span>(<span class="hljs-string">'https://api.example.com/v1/payments'</span>, {
    <span class="hljs-attr">method</span>: <span class="hljs-string">'POST'</span>,
    <span class="hljs-attr">headers</span>: { <span class="hljs-string">'content-type'</span>: <span class="hljs-string">'application/json'</span>, <span class="hljs-string">'idempotency-key'</span>: key },
    <span class="hljs-attr">body</span>: <span class="hljs-title">JSON</span>.<span class="hljs-title">stringify</span>({ <span class="hljs-attr">amount</span>: <span class="hljs-number">4200</span>, <span class="hljs-attr">currency</span>: <span class="hljs-string">'usd'</span> }),
    <span class="hljs-attr">signal</span>: <span class="hljs-title">AbortSignal</span>.<span class="hljs-title">timeout</span>(<span class="hljs-number">10_000</span>),
  }).<span class="hljs-title">catch</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-literal">null</span>);
  <span class="hljs-keyword">if</span> (res &amp;&amp; res.<span class="hljs-property">status</span> !== <span class="hljs-number">409</span> &amp;&amp; res.<span class="hljs-property">status</span> &lt; <span class="hljs-number">500</span>) <span class="hljs-keyword">break</span>;
  <span class="hljs-keyword">await</span> <span class="hljs-keyword">new</span> <span class="hljs-title">Promise</span>(<span class="hljs-function">(<span class="hljs-params">r</span>) =&gt;</span> <span class="hljs-built_in">setTimeout</span>(r, <span class="hljs-number">2</span> ** attempt * <span class="hljs-number">250</span>));
}
</code></pre></div>
<p>Never use an email address or other personal data as the key.</p>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<ul>
<li>Add the <code>idempotency_keys</code> table and middleware to every endpoint that charges, sends or creates something a user would notice twice.</li>
<li>Scope keys per account, fingerprint the payload, and return 409 and 422 as above.</li>
<li>Purge keys older than 24 hours.</li>
</ul>
<p>A one-person team can ship this in an afternoon for the one payment route that matters. A ten-person team should make it a shared middleware and add “is this POST idempotent?” to the review checklist, next to <a href="/blog/nodejs-rate-limiting">rate limiting</a>.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="is-an-idempotency-key-the-same-as-a-request-id">Is an idempotency key the same as a request ID? <a class="post-heading-anchor" href="#is-an-idempotency-key-the-same-as-a-request-id" aria-hidden="true">#</a></h3>
<p>No. A request ID identifies one HTTP attempt and changes on every retry. An idempotency key identifies the user’s intent and stays the same across retries.</p>
<h3 id="should-the-idempotency-key-go-in-a-header-or-the-body">Should the idempotency key go in a header or the body? <a class="post-heading-anchor" href="#should-the-idempotency-key-go-in-a-header-or-the-body" aria-hidden="true">#</a></h3>
<p>A header. It keeps the business payload clean, works the same on every endpoint, and matches the IETF draft and Stripe’s API.</p>
<h3 id="how-do-i-test-idempotency">How do I test idempotency? <a class="post-heading-anchor" href="#how-do-i-test-idempotency" aria-hidden="true">#</a></h3>
<p>Send the same request twice in parallel and check that only one row was created and the second call got a 409 or the replayed response. Then send the same key with a changed body and expect 422.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not hunt for duplicate charges in raw logs, ApiTraffic records every request and response, so you can <a href="https://www.apitraffic.io/docs/tags">tag each one with its key</a> and see every attempt side by side:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">apitraffic.<span class="hljs-title">tag</span>(<span class="hljs-string">'idempotencyKey'</span>, key);
</code></pre></div>
<p>Then <a href="https://www.apitraffic.io/docs/traffic/search">search for conflicts</a>, <code>url_path == "/v1/payments" AND status_code == 409</code>, or for one customer’s attempts with <code>tag:userId == "1042" AND status_code &gt;= 400</code>. <a href="https://www.apitraffic.io/docs/outbound-requests">Outbound calls are logged too</a>, so you can confirm your payment provider received the request once.</p>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260603-idempotency-keys-node">apitraffic.io</a></p>
<p>Sources: <a href="https://www.ietf.org/archive/id/draft-ietf-httpapi-idempotency-key-header-07.html" target="_blank" rel="noopener noreferrer">IETF, “The Idempotency-Key HTTP Header Field”, draft-ietf-httpapi-idempotency-key-header-07 (Oct 15, 2025)</a>; <a href="https://www.rfc-editor.org/rfc/rfc9110#section-9.2.2" target="_blank" rel="noopener noreferrer">RFC 9110, HTTP Semantics, section 9.2.2 (June 2022)</a>; <a href="https://docs.stripe.com/api/idempotent_requests" target="_blank" rel="noopener noreferrer">Stripe API reference, “Idempotent requests”</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Node.js memory leak debugging with heap snapshots</title>
      <link>https://www.apitraffic.io/blog/nodejs-memory-leak-heap</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/nodejs-memory-leak-heap</guid>
      <pubDate>Mon, 25 May 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>The container keeps getting OOM-killed and restarts hide why. Here is the three-snapshot method that finds a Node.js memory leak, and how to do it safely in production.</description>
      <content:encoded><![CDATA[<p>The container was OOM-killed at 3am again. Memory climbs after every deploy, the platform restarts the process, and the climb starts over. Restarts hide a Node.js memory leak until traffic grows enough that they stop hiding it.</p>
<p>The short answer: confirm it is a leak by watching <code>heapUsed</code> low points rise over hours, take three heap snapshots under the same traffic, and <a href="https://developer.chrome.com/docs/devtools/memory-problems/heap-snapshots" target="_blank" rel="noopener noreferrer">compare them in Chrome DevTools</a>. Objects that grow between every pair of snapshots are your leak, and the Retainers panel shows the code holding them. Below is that workflow, with the safety rules for doing it in production.</p>
<h2 id="how-to-tell-if-you-have-a-node-js-memory-leak">How to tell if you have a Node.js memory leak <a class="post-heading-anchor" href="#how-to-tell-if-you-have-a-node-js-memory-leak" aria-hidden="true">#</a></h2>
<p>Not every rising graph is a leak. V8 grows the heap lazily, caches fill up to their limits, and RSS includes native memory the JavaScript heap does not. Log the numbers separately first:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// memory-log.js</span>
<span class="hljs-keyword">const</span> <span class="hljs-variable">MB</span> = <span class="hljs-number">1024</span> * <span class="hljs-number">1024</span>;
<span class="hljs-built_in">setInterval</span>(<span class="hljs-function">() =&gt;</span> {
  <span class="hljs-keyword">const</span> m = process.<span class="hljs-title">memoryUsage</span>();
  <span class="hljs-variable">console</span>.<span class="hljs-title">log</span>(<span class="hljs-title">JSON</span>.<span class="hljs-title">stringify</span>({
    <span class="hljs-attr">msg</span>: <span class="hljs-string">'memory'</span>,
    <span class="hljs-attr">rssMb</span>: <span class="hljs-title">Math</span>.<span class="hljs-title">round</span>(m.<span class="hljs-property">rss</span> / <span class="hljs-variable">MB</span>),
    <span class="hljs-attr">heapUsedMb</span>: <span class="hljs-title">Math</span>.<span class="hljs-title">round</span>(m.<span class="hljs-property">heapUsed</span> / <span class="hljs-variable">MB</span>),
    <span class="hljs-attr">heapTotalMb</span>: <span class="hljs-title">Math</span>.<span class="hljs-title">round</span>(m.<span class="hljs-property">heapTotal</span> / <span class="hljs-variable">MB</span>),
    <span class="hljs-attr">externalMb</span>: <span class="hljs-title">Math</span>.<span class="hljs-title">round</span>(m.<span class="hljs-property">external</span> / <span class="hljs-variable">MB</span>),
    <span class="hljs-attr">arrayBuffersMb</span>: <span class="hljs-title">Math</span>.<span class="hljs-title">round</span>(m.<span class="hljs-property">arrayBuffers</span> / <span class="hljs-variable">MB</span>),
  }));
}, <span class="hljs-number">30_000</span>).<span class="hljs-title">unref</span>();
</code></pre></div>
<div class="post-table"><table>
<thead>
<tr>
<th>What you see over a few hours</th>
<th>What it probably means</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>heapUsed</code> sawtooth with rising low points</td>
<td>JavaScript heap leak; snapshots will find it</td>
</tr>
<tr>
<td><code>heapUsed</code> flat, <code>rss</code> or <code>external</code> rising</td>
<td>Native memory or buffers; snapshots help less</td>
</tr>
<tr>
<td>Everything flat once traffic settles</td>
<td>No leak; the heap is sized for your peak</td>
</tr>
</tbody>
</table></div>
<p>Check one more thing either way. If the container limit is 512 MB and V8 believes it can grow past that, the kernel kills the process before V8 collects hard. Set <code>--max-old-space-size</code> below the limit to leave room for native memory.</p>
<h2 id="common-causes-of-memory-leaks-in-node-apis">Common causes of memory leaks in Node APIs <a class="post-heading-anchor" href="#common-causes-of-memory-leaks-in-node-apis" aria-hidden="true">#</a></h2>
<ul>
<li>Module-level maps and arrays used as caches with no size limit or expiry.</li>
<li>Listeners added per request to a long-lived emitter and never removed. Node prints <code>MaxListenersExceededWarning</code> once an emitter passes 10 listeners for one event. Do not silence it.</li>
<li>Timers or intervals holding closures that are never cleared.</li>
<li>Per-request data pushed onto a singleton or the app instance.</li>
<li>Promises that never settle, often an outbound call with no timeout. See <a href="/blog/nodejs-fetch-timeout">timeouts with fetch, undici and AbortSignal</a>.</li>
<li>Unbounded queues where the consumer is slower than the producer.</li>
</ul>
<p>A deliberately leaky <a href="https://www.apitraffic.io/express">Express</a> route to practise on:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">const</span> app = <span class="hljs-title">express</span>();
<span class="hljs-keyword">const</span> seen = <span class="hljs-keyword">new</span> <span class="hljs-title">Map</span>(); <span class="hljs-comment">// grows forever</span>

app.<span class="hljs-title">get</span>(<span class="hljs-string">'/v1/quotes/:id'</span>, <span class="hljs-function">(<span class="hljs-params">req, res</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> key = <span class="hljs-string">`<span class="hljs-subst">${req.params.id}</span>:<span class="hljs-subst">${req.get(<span class="hljs-string">'x-request-id'</span>) ?? crypto.randomUUID()}</span>`</span>;
  seen.<span class="hljs-title">set</span>(key, { <span class="hljs-attr">at</span>: <span class="hljs-title">Date</span>.<span class="hljs-title">now</span>(), <span class="hljs-attr">headers</span>: { ...req.<span class="hljs-property">headers</span> } });
  res.<span class="hljs-title">json</span>({ <span class="hljs-attr">id</span>: req.<span class="hljs-property">params</span>.<span class="hljs-property">id</span>, <span class="hljs-attr">price</span>: <span class="hljs-number">42</span> });
});

app.<span class="hljs-title">listen</span>(<span class="hljs-number">3000</span>);
</code></pre></div>
<p>Every request adds an entry holding a copy of the headers. Run <code>npx autocannon -d 60 http://localhost:3000/v1/quotes/7</code> and watch <code>heapUsed</code> climb.</p>
<h2 id="how-to-take-a-heap-snapshot-in-node-js">How to take a heap snapshot in Node.js <a class="post-heading-anchor" href="#how-to-take-a-heap-snapshot-in-node-js" aria-hidden="true">#</a></h2>
<p>The <a href="https://nodejs.org/en/learn/diagnostics/memory/using-heap-snapshot" target="_blank" rel="noopener noreferrer">Node.js docs are blunt about cost</a>: taking a snapshot stops all other work on the main thread, can take more than a minute on a large heap, and is built in memory, so it can double heap size and crash the process. In production, take snapshots from an instance you can afford to lose, after removing it from load balancer rotation.</p>
<h3 id="from-a-signal-with-no-code-changes">From a signal, with no code changes <a class="post-heading-anchor" href="#from-a-signal-with-no-code-changes" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">bash</span></div><pre><code class="language-bash hljs">node --heapsnapshot-signal=SIGUSR2 server.js
<span class="hljs-built_in">kill</span> -USR2 &lt;pid&gt;
<span class="hljs-comment"># writes Heap.&lt;date&gt;.&lt;time&gt;.&lt;pid&gt;.&lt;tid&gt;.&lt;seq&gt;.heapsnapshot to the working directory</span>
</code></pre></div>
<h3 id="from-code-with-v8-writeheapsnapshot">From code with v8.writeHeapSnapshot() <a class="post-heading-anchor" href="#from-code-with-v8-writeheapsnapshot" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> { writeHeapSnapshot } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:v8'</span>;

app.<span class="hljs-title">post</span>(<span class="hljs-string">'/internal/heap-snapshot'</span>, requireAdmin, <span class="hljs-function">(<span class="hljs-params">req, res</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> file = <span class="hljs-title">writeHeapSnapshot</span>(); <span class="hljs-comment">// returns the filename</span>
  res.<span class="hljs-title">json</span>({ file });
});
</code></pre></div>
<p>Never expose that route publicly. A snapshot contains whatever was in memory, including tokens, request bodies and personal data. Treat the file like a database dump and delete it when you are done.</p>
<h3 id="automatically-near-the-heap-limit">Automatically, near the heap limit <a class="post-heading-anchor" href="#automatically-near-the-heap-limit" aria-hidden="true">#</a></h3>
<p><a href="https://nodejs.org/api/cli.html" target="_blank" rel="noopener noreferrer"><code>--heapsnapshot-near-heap-limit=1</code></a> writes a snapshot as the heap approaches its limit, capturing the worst state. It needs headroom to succeed, so pair it with a sensible <code>--max-old-space-size</code>.</p>
<h3 id="locally-with-the-inspector">Locally, with the inspector <a class="post-heading-anchor" href="#locally-with-the-inspector" aria-hidden="true">#</a></h3>
<p>Start with <code>node --inspect server.js</code>, open <code>chrome://inspect</code>, click “inspect” under your process, and use the Memory tab’s “Take snapshot” button.</p>
<h2 id="how-to-compare-heap-snapshots-in-chrome-devtools">How to compare heap snapshots in Chrome DevTools <a class="post-heading-anchor" href="#how-to-compare-heap-snapshots-in-chrome-devtools" aria-hidden="true">#</a></h2>
<p>One snapshot shows what is in memory, not what is growing. Use three:</p>
<ol>
<li>Start the process and let it warm up.</li>
<li>Take snapshot 1.</li>
<li>Send steady traffic to the suspect route for a few minutes.</li>
<li>Take snapshot 2.</li>
<li>Send the same traffic again.</li>
<li>Take snapshot 3.</li>
</ol>
<p>Load them oldest first. Select snapshot 3, switch the view from Summary to Comparison against snapshot 2, and sort by “# Delta” or “Size Delta”. Objects that grew between 1 and 2 and again between 2 and 3 are leak candidates. Objects that grew once are usually caches warming up.</p>
<p>For the leaky route, the comparison shows thousands of new objects and strings. Expand one and read the Retainers panel: the object is held by a <code>Map</code> entry, the <code>Map</code> by <code>seen</code> in the module’s context, the context by the route handler. That points at the line.</p>
<p>Reading tips:</p>
<ul>
<li>Shallow size is the object itself. Retained size is everything freed if it went away. Sort by retained size to find the root.</li>
<li>Skip <code>(compiled code)</code> and <code>(system)</code> entries at first.</li>
<li>Retainer paths through <code>EventEmitter</code> and <code>_events</code> mean a listener leak. Paths through <code>Timeout</code> mean a timer holding a closure.</li>
</ul>
<h2 id="fixing-the-leak-and-proving-it">Fixing the leak and proving it <a class="post-heading-anchor" href="#fixing-the-leak-and-proving-it" aria-hidden="true">#</a></h2>
<p>For a cache, add a bound and an expiry. An LRU library or a size check both work:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">const</span> <span class="hljs-variable">MAX</span> = <span class="hljs-number">10_000</span>;
<span class="hljs-keyword">function</span> <span class="hljs-title">remember</span>(<span class="hljs-params">key, value</span>) {
  <span class="hljs-keyword">if</span> (seen.<span class="hljs-property">size</span> &gt;= <span class="hljs-variable">MAX</span>) seen.<span class="hljs-title">delete</span>(seen.<span class="hljs-title">keys</span>().<span class="hljs-title">next</span>().<span class="hljs-property">value</span>);
  seen.<span class="hljs-title">set</span>(key, value);
}
</code></pre></div>
<p>Repeat the three-snapshot run. The fix is proven when snapshot 3 shows no consistent growth over snapshot 2, and <code>heapUsed</code> low points stay flat for hours in production.</p>
<p>On a one-person team, do the whole investigation locally with the inspector and a load tool; production snapshots are rarely needed. On a ten-person team, add <code>--heapsnapshot-signal</code> to the start command now, write down who can pull an instance out of rotation, and keep the memory log line in every service.</p>
<h2 id="memory-leak-checklist">Memory leak checklist <a class="post-heading-anchor" href="#memory-leak-checklist" aria-hidden="true">#</a></h2>
<p>If you only do one thing, log <code>heapUsed</code> and <code>rss</code> every 30 seconds so the next leak is diagnosed from a graph instead of a guess. Then:</p>
<ul>
<li><code>--max-old-space-size</code> fits inside the container limit.</li>
<li>Every module-level cache has a size limit or TTL.</li>
<li><code>MaxListenersExceededWarning</code> is treated as a bug.</li>
<li>Every outbound call has a timeout.</li>
<li>Snapshot files are deleted after analysis.</li>
</ul>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="how-do-i-find-a-memory-leak-in-node-js">How do I find a memory leak in Node.js? <a class="post-heading-anchor" href="#how-do-i-find-a-memory-leak-in-node-js" aria-hidden="true">#</a></h3>
<p>Confirm <code>heapUsed</code> keeps rising after garbage collection, then take three heap snapshots under the same load and compare them in Chrome DevTools. The Retainers panel shows which code keeps the growing objects alive.</p>
<h3 id="is-it-safe-to-take-a-heap-snapshot-in-production">Is it safe to take a heap snapshot in production? <a class="post-heading-anchor" href="#is-it-safe-to-take-a-heap-snapshot-in-production" aria-hidden="true">#</a></h3>
<p>Only on an instance that can crash without hurting users. The process pauses while the snapshot is written, and memory use can double.</p>
<h3 id="why-is-rss-growing-when-heapused-is-flat">Why is RSS growing when heapUsed is flat? <a class="post-heading-anchor" href="#why-is-rss-growing-when-heapused-is-flat" aria-hidden="true">#</a></h3>
<p>The growth is outside the JavaScript heap: buffers, native addons or compression contexts. Check <code>external</code> and <code>arrayBuffers</code> in <code>process.memoryUsage()</code>.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>Heap snapshots show what is held in memory, not which traffic caused it. If you would rather not wire up that correlation yourself:</p>
<ul>
<li>ApiTraffic records requests and <a href="https://www.apitraffic.io/docs/endpoint-handling">p50, p95 and p99 per endpoint</a> over time, so latency that rises with memory shows on the endpoint doing the extra work. Read those numbers with <a href="/blog/api-latency-percentiles">latency percentiles in mind</a>.</li>
<li><a href="https://www.apitraffic.io/docs/apm/analytics">Regressions</a> compares p95 before and after a <a href="https://www.apitraffic.io/docs/events">recorded deploy event</a>, narrowing a new leak to the release that introduced it.</li>
<li><code>span()</code> and <code>trace()</code> add custom timings around a suspect cache path inside each request.</li>
</ul>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260525-nodejs-memory-leak-heap">apitraffic.io</a></p>
<p>Sources: <a href="https://nodejs.org/en/learn/diagnostics/memory/using-heap-snapshot" target="_blank" rel="noopener noreferrer">Node.js, “Using Heap Snapshot”</a>; <a href="https://nodejs.org/api/cli.html" target="_blank" rel="noopener noreferrer">Node.js CLI options</a>; <a href="https://nodejs.org/api/v8.html" target="_blank" rel="noopener noreferrer">Node.js v8.writeHeapSnapshot</a>; <a href="https://developer.chrome.com/docs/devtools/memory-problems/heap-snapshots" target="_blank" rel="noopener noreferrer">Chrome DevTools, “Record heap snapshots”</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Node.js log levels that mean something: a logging policy for small teams</title>
      <link>https://www.apitraffic.io/blog/nodejs-log-levels</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/nodejs-log-levels</guid>
      <pubDate>Wed, 29 Apr 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>At 3am your error log is full of scanner 404s and the real database timeout is buried. Here is a Node.js log levels policy a small team can paste into its repo, plus the code that enforces it.</description>
      <content:encoded><![CDATA[<p>It is 3am, the error count is climbing, and when you open the logs half the <code>error</code> lines are 404s from a scanner and expired tokens from a mobile client. The real failure, a database timeout, is in there somewhere. Node.js log levels only help if everyone on the team uses them the same way, and on a small team nobody has ever written down what that way is.</p>
<p>The direct answer: tie each level to an action. <code>error</code> means someone should look at it today, <code>warn</code> means watch the trend, <code>info</code> records events you will want during an investigation, and <code>debug</code> is off in production. Client mistakes (4xx) are never <code>error</code>. Log each failure once, at the boundary where you handle it. The rest of this post is a policy you can paste into your repo, with the code to enforce it.</p>
<h2 id="what-each-log-level-means-a-policy-you-can-copy">What each log level means: a policy you can copy <a class="post-heading-anchor" href="#what-each-log-level-means-a-policy-you-can-copy" aria-hidden="true">#</a></h2>
<p>Pino, Fastify’s logger and most Node loggers use the same six levels, with <a href="https://github.com/pinojs/pino/blob/main/docs/api.md" target="_blank" rel="noopener noreferrer">numeric values in Pino</a> of <code>trace</code> 10, <code>debug</code> 20, <code>info</code> 30, <code>warn</code> 40, <code>error</code> 50 and <code>fatal</code> 60. Syslog (<a href="https://www.rfc-editor.org/rfc/rfc5424#section-6.2.1" target="_blank" rel="noopener noreferrer">RFC 5424</a>) has eight severities; you do not need that many.</p>
<div class="post-table"><table>
<thead>
<tr>
<th>Level</th>
<th>Meaning</th>
<th>Who acts</th>
<th>Examples</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>fatal</code></td>
<td>The process is about to exit</td>
<td>On-call, now</td>
<td>Cannot connect to the database at startup; uncaught exception</td>
</tr>
<tr>
<td><code>error</code></td>
<td>A request or job failed and the user saw it</td>
<td>Someone, today</td>
<td>Unhandled exception in a handler; payment provider down after retries</td>
</tr>
<tr>
<td><code>warn</code></td>
<td>Something unexpected that was handled</td>
<td>Nobody, unless the rate changes</td>
<td>Retry succeeded on second attempt; cache unavailable, fell back to DB</td>
</tr>
<tr>
<td><code>info</code></td>
<td>Normal events you want during an investigation</td>
<td>Nobody</td>
<td>Server started; deploy version; order created; webhook received</td>
</tr>
<tr>
<td><code>debug</code></td>
<td>Detail for diagnosing a specific problem</td>
<td>You, temporarily</td>
<td>Query parameters; branch taken; parsed config</td>
</tr>
<tr>
<td><code>trace</code></td>
<td>Very fine-grained detail</td>
<td>Rarely anyone</td>
<td>Per-iteration loop values</td>
</tr>
</tbody>
</table></div>
<p>The test for any log line: if this fired 1,000 times tonight, would you want to be woken up? If yes, it is <code>error</code> or <code>fatal</code>. If you would want to see the count on a dashboard, <code>warn</code>. Otherwise <code>info</code> or lower.</p>
<h2 id="why-4xx-responses-are-not-errors">Why 4xx responses are not errors <a class="post-heading-anchor" href="#why-4xx-responses-are-not-errors" aria-hidden="true">#</a></h2>
<p>A <code>400</code>, <code>401</code>, <code>403</code>, <code>404</code> or <code>422</code> means the client did something wrong and your code did the right thing by refusing. Logging them at <code>error</code> is the most common reason small teams stop trusting their error logs. Scanners, expired sessions and buggy clients produce thousands of them.</p>
<p>Log 4xx at <code>info</code> (or not at all, if your request logger already records the status). Promote a pattern to <code>warn</code> only when the volume itself is the signal, such as a spike in 401s on login. A <code>429</code> from your own rate limiter is <code>info</code>; a <code>429</code> you received from a third-party API is <code>warn</code>.</p>
<h2 id="how-to-log-each-failure-once">How to log each failure once <a class="post-heading-anchor" href="#how-to-log-each-failure-once" aria-hidden="true">#</a></h2>
<p>The second most common problem is the same failure logged three times: in the repository, in the service and in the error handler. Each line looks like a separate incident. The rule: <strong>either handle an error or throw it, never both.</strong> Log where you handle.</p>
<p>Carry the intended level on the error itself, so the one place that logs knows what to do:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// errors.js</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">class</span> <span class="hljs-title">AppError</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">Error</span> {
  <span class="hljs-title">constructor</span>(<span class="hljs-params">message, { status = <span class="hljs-number">500</span>, code = <span class="hljs-string">'internal_error'</span>, level, cause } = {}</span>) {
    <span class="hljs-variable">super</span>(message, { cause });
    <span class="hljs-variable">this</span>.<span class="hljs-property">status</span> = status;
    <span class="hljs-variable">this</span>.<span class="hljs-property">code</span> = code;
    <span class="hljs-variable">this</span>.<span class="hljs-property">level</span> = level ?? (status &gt;= <span class="hljs-number">500</span> ? <span class="hljs-string">'error'</span> : <span class="hljs-string">'info'</span>);
  }
}

<span class="hljs-comment">// usage in a handler</span>
<span class="hljs-keyword">if</span> (!order) <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title">AppError</span>(<span class="hljs-string">'order not found'</span>, { <span class="hljs-attr">status</span>: <span class="hljs-number">404</span>, <span class="hljs-attr">code</span>: <span class="hljs-string">'not_found'</span> });
</code></pre></div>
<p>Then a single <a href="https://www.apitraffic.io/express">Express</a> error handler does the logging:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">app.<span class="hljs-title">use</span>(<span class="hljs-function">(<span class="hljs-params">err, req, res, next</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> status = err.<span class="hljs-property">status</span> ?? <span class="hljs-number">500</span>;
  <span class="hljs-keyword">const</span> level = err.<span class="hljs-property">level</span> ?? <span class="hljs-string">'error'</span>;
  req.<span class="hljs-property">log</span>[level]({ err, <span class="hljs-attr">code</span>: err.<span class="hljs-property">code</span> }, err.<span class="hljs-property">message</span>);
  res.<span class="hljs-title">status</span>(status).<span class="hljs-title">json</span>({ <span class="hljs-attr">error</span>: err.<span class="hljs-property">code</span> ?? <span class="hljs-string">'internal_error'</span> });
});
</code></pre></div>
<p>If you also use pino-http (see <a href="/blog/pino-logging-nodejs">Pino logging in Node.js</a>), it logs a line for every completed response too. Decide which one owns errors. A simple split: pino-http logs every request at <code>info</code> with its status, and only the error handler logs at <code>error</code> with the stack. Set pino-http’s <code>customLogLevel</code> to return <code>info</code> even for 5xx, so the stack trace is not duplicated.</p>
<h2 id="how-to-change-the-log-level-in-production-without-a-deploy">How to change the log level in production without a deploy <a class="post-heading-anchor" href="#how-to-change-the-log-level-in-production-without-a-deploy" aria-hidden="true">#</a></h2>
<p>Running at <code>info</code> in production is the right default. When you are chasing a bug you want <code>debug</code> for ten minutes, not a redeploy. Two cheap options:</p>
<h3 id="option-1-an-environment-variable">Option 1: an environment variable <a class="post-heading-anchor" href="#option-1-an-environment-variable" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">const</span> logger = <span class="hljs-title">pino</span>({ <span class="hljs-attr">level</span>: process.<span class="hljs-property">env</span>.<span class="hljs-property">LOG_LEVEL</span> ?? <span class="hljs-string">'info'</span> });
</code></pre></div>
<p>On Render, Railway, <a href="http://Fly.io" target="_blank" rel="noopener noreferrer">Fly.io</a> or Heroku, changing an environment variable restarts the service. That is fine for a solo developer and loses in-memory state.</p>
<h3 id="option-2-a-signal">Option 2: a signal <a class="post-heading-anchor" href="#option-2-a-signal" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">process.<span class="hljs-title">on</span>(<span class="hljs-string">'SIGUSR2'</span>, <span class="hljs-function">() =&gt;</span> {
  logger.<span class="hljs-property">level</span> = logger.<span class="hljs-property">level</span> === <span class="hljs-string">'debug'</span> ? <span class="hljs-string">'info'</span> : <span class="hljs-string">'debug'</span>;
  logger.<span class="hljs-title">warn</span>({ <span class="hljs-attr">newLevel</span>: logger.<span class="hljs-property">level</span> }, <span class="hljs-string">'log level changed'</span>);
});
</code></pre></div>
<p><code>kill -USR2 &lt;pid&gt;</code> flips the level with no restart. Use <code>SIGUSR2</code>, not <code>SIGUSR1</code>, which <a href="https://nodejs.org/api/process.html#signal-events" target="_blank" rel="noopener noreferrer">Node reserves for starting the inspector</a>. Log the change at <code>warn</code>, so anyone reading later knows why the volume jumped.</p>
<p>For per-module control, create child loggers and set levels on them: <code>const dbLog = logger.child({ module: 'db' }, { level: 'debug' })</code>.</p>
<h2 id="node-js-log-levels-and-your-log-bill">Node.js log levels and your log bill <a class="post-heading-anchor" href="#node-js-log-levels-and-your-log-bill" aria-hidden="true">#</a></h2>
<p>Levels are also a cost control. Most log platforms bill by volume ingested or retained, and debug output left on in production is a classic surprise invoice. Three habits keep it in check:</p>
<ul>
<li>Do not log request or response bodies at <code>info</code>. If you need them, log at <code>debug</code> with redaction (see <a href="/blog/redact-sensitive-data-logs">how to redact sensitive data in logs</a>).</li>
<li>One line per request, not one per middleware.</li>
<li>Outbound calls: log failures and slow calls, not every success. A dedicated approach to <a href="/blog/log-outbound-http-requests">logging outbound HTTP requests</a> covers this in detail.</li>
</ul>
<h2 id="if-you-only-do-one-thing-paste-this-policy-into-your-repo">If you only do one thing: paste this policy into your repo <a class="post-heading-anchor" href="#if-you-only-do-one-thing-paste-this-policy-into-your-repo" aria-hidden="true">#</a></h2>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">markdown</span></div><pre><code class="language-markdown hljs"><span class="hljs-section">## Logging policy</span>
<span class="hljs-bullet">-</span> fatal: process will exit. Pages on-call.
<span class="hljs-bullet">-</span> error: a user-visible failure we did not expect. Reviewed daily.
<span class="hljs-bullet">-</span> warn: unexpected but handled (retries, fallbacks). Watched as a rate.
<span class="hljs-bullet">-</span> info: lifecycle and business events. Default level in production.
<span class="hljs-bullet">-</span> debug/trace: off in production; enable temporarily with SIGUSR2.
<span class="hljs-bullet">-</span> 4xx responses are never error.
<span class="hljs-bullet">-</span> Log an error once, where it is handled. Never log and rethrow.
<span class="hljs-bullet">-</span> Errors are logged as { err }, never err.message.
</code></pre></div>
<p>A solo developer can adopt this in an afternoon: grep for <code>log.error</code> and <code>console.error</code>, demote the 4xx cases and delete the log-and-rethrow lines. A team of ten should add it to the PR checklist so the next new endpoint follows it, and review the <code>error</code> count weekly until it is quiet enough that a new error is news.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="what-log-level-should-i-use-in-production-for-node-js">What log level should I use in production for Node.js? <a class="post-heading-anchor" href="#what-log-level-should-i-use-in-production-for-node-js" aria-hidden="true">#</a></h3>
<p>Use <code>info</code>. It records lifecycle and business events without the volume of <code>debug</code>. Enable <code>debug</code> temporarily when investigating, then turn it back off.</p>
<h3 id="should-a-404-be-logged-as-an-error">Should a 404 be logged as an error? <a class="post-heading-anchor" href="#should-a-404-be-logged-as-an-error" aria-hidden="true">#</a></h3>
<p>No. A 404 means your API correctly refused a request for something that does not exist. Log it at <code>info</code>, and alert only on unusual volume, which often indicates scanning.</p>
<h3 id="what-is-the-difference-between-warn-and-error">What is the difference between warn and error? <a class="post-heading-anchor" href="#what-is-the-difference-between-warn-and-error" aria-hidden="true">#</a></h3>
<p><code>error</code> means a request or job failed in a way the user noticed and nobody expected. <code>warn</code> means something went wrong but was handled, such as a retry that succeeded. Errors deserve individual attention; warnings deserve a trend line.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not keep per-request log lines at all, ApiTraffic <a href="https://www.apitraffic.io/docs/traffic/request-log">records every inbound request and response</a> (method, path, status, latency, headers, bodies) from <a href="https://www.apitraffic.io/docs/quickstart">one middleware</a>, independent of your log levels. Your application logs can then stay at <code>info</code> or <code>warn</code> for application events, and the request record is still there when you need it.</p>
<p>Finding the failures that matter becomes <a href="https://www.apitraffic.io/docs/traffic/search">a search</a>, for example <code>status_code &gt;= 500</code> for one endpoint, or <code>tag:userId == "1042" AND status_code &gt;= 400</code> when a specific customer reports a problem.</p>
<p>Start free and keep a full request record without noisy logs: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260429-nodejs-log-levels">apitraffic.io</a></p>
<p>Sources: <a href="https://github.com/pinojs/pino/blob/main/docs/api.md" target="_blank" rel="noopener noreferrer">Pino API: logger.level and levels</a>; <a href="https://www.rfc-editor.org/rfc/rfc5424#section-6.2.1" target="_blank" rel="noopener noreferrer">RFC 5424, The Syslog Protocol, severity levels</a>; <a href="https://nodejs.org/api/process.html#signal-events" target="_blank" rel="noopener noreferrer">Node.js docs: signal events (SIGUSR1 reserved for the debugger)</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Fastify hooks for logging and auth: onRequest, preHandler and onResponse explained</title>
      <link>https://www.apitraffic.io/blog/fastify-hooks-logging-auth</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/fastify-hooks-logging-auth</guid>
      <pubDate>Mon, 20 Apr 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>Auth checks that run before the body exists, log lines missing the status code, handlers that run after a 401: most come from using the wrong Fastify hook. Here is which hook does which job in Fastify v5, with working code.</description>
      <content:encoded><![CDATA[<p>Most <a href="https://www.apitraffic.io/fastify">Fastify</a> bugs around auth and logging come from putting code in the wrong hook: a permission check that runs before the body exists, a log line written before the status code is known, or a hook that sends a reply and then lets the handler run anyway.</p>
<p>The short answer: authenticate in <code>onRequest</code> (it runs first and has headers, but no body), authorize in <code>preHandler</code> (body, params and validation are done), and log in <code>onResponse</code> (the status and <code>reply.elapsedTime</code> are final). This post walks through the request lifecycle in Fastify v5, shows working code for each of those three jobs, and lists the mistakes that cause double responses and missing log fields.</p>
<h2 id="the-fastify-hooks-lifecycle-in-order">The Fastify hooks lifecycle, in order <a class="post-heading-anchor" href="#the-fastify-hooks-lifecycle-in-order" aria-hidden="true">#</a></h2>
<p>Fastify runs these request and reply hooks for every request, in this order:</p>
<div class="post-table"><table>
<thead>
<tr>
<th>Hook</th>
<th>Body parsed?</th>
<th>Can send a reply?</th>
<th>Good for</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>onRequest</code></td>
<td>No, <code>request.body</code> is <code>undefined</code></td>
<td>Yes</td>
<td>Authentication, request IDs, cheap rejections</td>
</tr>
<tr>
<td><code>preParsing</code></td>
<td>No</td>
<td>Yes</td>
<td>Wrapping or limiting the raw body stream</td>
</tr>
<tr>
<td><code>preValidation</code></td>
<td>Yes</td>
<td>Yes</td>
<td>Normalizing the body before schema validation</td>
</tr>
<tr>
<td><code>preHandler</code></td>
<td>Yes, and validated</td>
<td>Yes</td>
<td>Authorization, loading the resource, per-route checks</td>
</tr>
<tr>
<td><code>preSerialization</code></td>
<td>n/a</td>
<td>Changes the payload</td>
<td>Wrapping response objects</td>
</tr>
<tr>
<td><code>onSend</code></td>
<td>n/a</td>
<td>Changes the serialized payload</td>
<td>Response headers, compression tweaks</td>
</tr>
<tr>
<td><code>onResponse</code></td>
<td>n/a</td>
<td>No, already sent</td>
<td>Logging, metrics</td>
</tr>
<tr>
<td><code>onError</code></td>
<td>n/a</td>
<td>No</td>
<td>Adding context to error logs</td>
</tr>
<tr>
<td><code>onTimeout</code>, <code>onRequestAbort</code></td>
<td>n/a</td>
<td>No</td>
<td>Noting dropped connections</td>
</tr>
</tbody>
</table></div>
<p>Two facts from the <a href="https://fastify.dev/docs/latest/Reference/Hooks/" target="_blank" rel="noopener noreferrer">Fastify docs</a> shape everything else. In <code>onRequest</code>, the body is always <code>undefined</code> because parsing happens later. And <code>onResponse</code> runs after the response has been sent, so you cannot change anything the client sees there.</p>
<h2 id="how-to-authenticate-in-the-onrequest-hook">How to authenticate in the onRequest hook <a class="post-heading-anchor" href="#how-to-authenticate-in-the-onrequest-hook" aria-hidden="true">#</a></h2>
<p>Authentication only needs headers, so do it as early as possible. Rejecting a bad token in <code>onRequest</code> means Fastify never parses a body from an anonymous caller, which matters when someone posts 1 MB of JSON at your login-protected endpoints.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// plugins/auth.js</span>
<span class="hljs-keyword">import</span> fp <span class="hljs-keyword">from</span> <span class="hljs-string">'fastify-plugin'</span>;
<span class="hljs-keyword">import</span> { verifyToken } <span class="hljs-keyword">from</span> <span class="hljs-string">'../lib/tokens.js'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-title">fp</span>(<span class="hljs-title">async</span> (app) =&gt; {
  app.<span class="hljs-title">decorateRequest</span>(<span class="hljs-string">'user'</span>, <span class="hljs-literal">null</span>);

  app.<span class="hljs-title">decorate</span>(<span class="hljs-string">'authenticate'</span>, <span class="hljs-title">async</span> (request, reply) =&gt; {
    <span class="hljs-keyword">const</span> header = request.<span class="hljs-property">headers</span>.<span class="hljs-property">authorization</span> ?? <span class="hljs-string">''</span>;
    <span class="hljs-keyword">const</span> token = header.<span class="hljs-title">startsWith</span>(<span class="hljs-string">'Bearer '</span>) ? header.<span class="hljs-title">slice</span>(<span class="hljs-number">7</span>) : <span class="hljs-literal">null</span>;
    <span class="hljs-keyword">const</span> user = token ? <span class="hljs-keyword">await</span> <span class="hljs-title">verifyToken</span>(token) : <span class="hljs-literal">null</span>;

    <span class="hljs-keyword">if</span> (!user) {
      <span class="hljs-comment">// Send, then return reply so Fastify stops the chain here.</span>
      <span class="hljs-keyword">return</span> reply.<span class="hljs-title">code</span>(<span class="hljs-number">401</span>).<span class="hljs-title">send</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'unauthorized'</span> });
    }
    request.<span class="hljs-property">user</span> = user;
  });
});
</code></pre></div>
<p><code>fastify-plugin</code> matters here. <a href="https://fastify.dev/docs/latest/Reference/Encapsulation/" target="_blank" rel="noopener noreferrer">Fastify encapsulates plugins</a>: every hook except <code>onClose</code>, and every decorator, is scoped to the plugin context where it was registered. Wrapping the auth plugin with <code>fp</code> makes <code>app.authenticate</code> visible to the rest of the app, while you still choose where the hook runs.</p>
<h2 id="how-to-authorize-in-the-prehandler-hook">How to authorize in the preHandler hook <a class="post-heading-anchor" href="#how-to-authorize-in-the-prehandler-hook" aria-hidden="true">#</a></h2>
<p>Authorization usually needs params and sometimes the body: “can this user read order 42”, “can this user move money from this account”. By <code>preHandler</code>, both are parsed and validated. Load the resource once, check ownership, and hand it to the handler:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// routes/orders.js</span>
<span class="hljs-keyword">import</span> { findOrder } <span class="hljs-keyword">from</span> <span class="hljs-string">'../lib/orders.js'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title">orders</span>(<span class="hljs-params">app</span>) {
  app.<span class="hljs-title">decorateRequest</span>(<span class="hljs-string">'order'</span>, <span class="hljs-literal">null</span>);
  app.<span class="hljs-title">addHook</span>(<span class="hljs-string">'onRequest'</span>, app.<span class="hljs-property">authenticate</span>);   <span class="hljs-comment">// every route in this plugin</span>

  <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title">loadOwnOrder</span>(<span class="hljs-params">request, reply</span>) {
    <span class="hljs-keyword">const</span> order = <span class="hljs-keyword">await</span> <span class="hljs-title">findOrder</span>(request.<span class="hljs-property">params</span>.<span class="hljs-property">id</span>);
    <span class="hljs-keyword">if</span> (!order || order.<span class="hljs-property">accountId</span> !== request.<span class="hljs-property">user</span>.<span class="hljs-property">accountId</span>) {
      <span class="hljs-comment">// 404, not 403: do not confirm that someone else's order exists.</span>
      <span class="hljs-keyword">return</span> reply.<span class="hljs-title">code</span>(<span class="hljs-number">404</span>).<span class="hljs-title">send</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'not found'</span> });
    }
    request.<span class="hljs-property">order</span> = order;
  }

  app.<span class="hljs-title">get</span>(<span class="hljs-string">'/v1/orders/:id'</span>, { <span class="hljs-attr">preHandler</span>: loadOwnOrder }, <span class="hljs-title">async</span> (request) =&gt; {
    <span class="hljs-keyword">return</span> request.<span class="hljs-property">order</span>;
  });
}
</code></pre></div>
<p>Route-level hooks accept a function or an array, and the docs note that they always run as the last hook in their category. That gives you a predictable order: plugin-wide auth in <code>onRequest</code>, then the route’s own checks in <code>preHandler</code>.</p>
<h2 id="logging-requests-in-the-onresponse-hook">Logging requests in the onResponse hook <a class="post-heading-anchor" href="#logging-requests-in-the-onresponse-hook" aria-hidden="true">#</a></h2>
<p>Fastify’s built-in Pino logger already logs “incoming request” and “request completed”. Those lines lack anything app-specific, like the user or the route pattern. Turn the default off and write one line per request yourself:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// app.js</span>
<span class="hljs-keyword">import</span> <span class="hljs-title">Fastify</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'fastify'</span>;
<span class="hljs-keyword">import</span> auth <span class="hljs-keyword">from</span> <span class="hljs-string">'./plugins/auth.js'</span>;
<span class="hljs-keyword">import</span> orders <span class="hljs-keyword">from</span> <span class="hljs-string">'./routes/orders.js'</span>;

<span class="hljs-keyword">const</span> app = <span class="hljs-title">Fastify</span>({
  <span class="hljs-attr">logger</span>: <span class="hljs-literal">true</span>,
  <span class="hljs-attr">disableRequestLogging</span>: <span class="hljs-literal">true</span>,
  <span class="hljs-attr">requestIdHeader</span>: <span class="hljs-string">'x-request-id'</span>,
});

app.<span class="hljs-title">addHook</span>(<span class="hljs-string">'onRequest'</span>, <span class="hljs-title">async</span> (request, reply) =&gt; {
  reply.<span class="hljs-title">header</span>(<span class="hljs-string">'x-request-id'</span>, request.<span class="hljs-property">id</span>);
});

app.<span class="hljs-title">addHook</span>(<span class="hljs-string">'onResponse'</span>, <span class="hljs-title">async</span> (request, reply) =&gt; {
  request.<span class="hljs-property">log</span>.<span class="hljs-title">info</span>({
    <span class="hljs-attr">method</span>: request.<span class="hljs-property">method</span>,
    <span class="hljs-attr">route</span>: request.<span class="hljs-property">routeOptions</span>.<span class="hljs-property">url</span> ?? <span class="hljs-string">'unmatched'</span>,
    <span class="hljs-attr">status</span>: reply.<span class="hljs-property">statusCode</span>,
    <span class="hljs-attr">ms</span>: <span class="hljs-title">Math</span>.<span class="hljs-title">round</span>(reply.<span class="hljs-property">elapsedTime</span>),
    <span class="hljs-attr">userId</span>: request.<span class="hljs-property">user</span>?.<span class="hljs-property">id</span>,
  }, <span class="hljs-string">'request completed'</span>);
});

app.<span class="hljs-title">addHook</span>(<span class="hljs-string">'onError'</span>, <span class="hljs-title">async</span> (request, reply, error) =&gt; {
  request.<span class="hljs-property">log</span>.<span class="hljs-title">error</span>({ <span class="hljs-attr">err</span>: error, <span class="hljs-attr">route</span>: request.<span class="hljs-property">routeOptions</span>.<span class="hljs-property">url</span> }, <span class="hljs-string">'request failed'</span>);
});

<span class="hljs-keyword">await</span> app.<span class="hljs-title">register</span>(auth);
<span class="hljs-keyword">await</span> app.<span class="hljs-title">register</span>(orders);
<span class="hljs-keyword">await</span> app.<span class="hljs-title">listen</span>({ <span class="hljs-attr">port</span>: <span class="hljs-number">3000</span>, <span class="hljs-attr">host</span>: <span class="hljs-string">'0.0.0.0'</span> });
</code></pre></div>
<p>Three <a href="https://fastify.dev/docs/latest/Guides/Migration-Guide-V5/" target="_blank" rel="noopener noreferrer">Fastify v5 details</a> are in that snippet. <code>reply.getResponseTime()</code> was removed in v5; use <code>reply.elapsedTime</code>. <code>request.routerPath</code> is deprecated; use <code>request.routeOptions.url</code>, which gives <code>/v1/orders/:id</code> instead of the raw URL, so your logs group by endpoint. And the custom-logger option moved: pass your own Pino instance as <code>loggerInstance</code>, not <code>logger</code>.</p>
<p>For carrying the request ID into code that does not receive <code>request</code>, see <a href="/blog/asynclocalstorage-request-id">request IDs and AsyncLocalStorage</a>. Before you add more fields to that log line, read <a href="/blog/redact-sensitive-data-logs">what not to log</a>.</p>
<h2 id="common-fastify-hook-mistakes">Common Fastify hook mistakes <a class="post-heading-anchor" href="#common-fastify-hook-mistakes" aria-hidden="true">#</a></h2>
<h3 id="mixing-async-and-done">Mixing async and done <a class="post-heading-anchor" href="#mixing-async-and-done" aria-hidden="true">#</a></h3>
<p>Fastify v5 no longer allows mixing the callback and promise styles in hooks and plugins. Either write <code>async (request, reply) =&gt; {}</code> or <code>(request, reply, done) =&gt; { ...; done() }</code>. A hook that is <code>async</code> and also calls <code>done</code> can run the chain twice.</p>
<h3 id="sending-without-returning">Sending without returning <a class="post-heading-anchor" href="#sending-without-returning" aria-hidden="true">#</a></h3>
<p>In an async hook, if you call <code>reply.send()</code> from a callback or timer instead of before the function returns, you must <code>return reply</code>. Otherwise Fastify treats the hook as finished and continues to the handler, and you get “Reply was already sent” errors.</p>
<h3 id="arrow-functions-and-this">Arrow functions and <code>this</code> <a class="post-heading-anchor" href="#arrow-functions-and-this" aria-hidden="true">#</a></h3>
<p>With <code>function</code> syntax, <code>this</code> is the Fastify instance. With arrow functions it is not. Use the <code>app</code> variable you closed over instead of <code>this</code>.</p>
<h3 id="logging-bodies-in-onrequest">Logging bodies in onRequest <a class="post-heading-anchor" href="#logging-bodies-in-onrequest" aria-hidden="true">#</a></h3>
<p>The body does not exist yet. If you need it in logs, read it in <code>preHandler</code>, and redact first.</p>
<h2 id="fastify-hooks-checklist">Fastify hooks checklist <a class="post-heading-anchor" href="#fastify-hooks-checklist" aria-hidden="true">#</a></h2>
<ul>
<li>Authentication: <code>onRequest</code>, through a decorator wrapped in <code>fastify-plugin</code>.</li>
<li>Authorization and resource loading: route <code>preHandler</code>, returning 404 for other people’s objects.</li>
<li>One log line per request: <code>onResponse</code>, with <code>routeOptions.url</code>, <code>statusCode</code>, <code>elapsedTime</code> and the user ID.</li>
<li>Errors: <code>onError</code> for context only; change error responses with <code>setErrorHandler</code>.</li>
<li>Pick async or callback style per hook, never both.</li>
</ul>
<p>On a one-person project, put all of this in <code>app.js</code> and one auth plugin. On a ten-person team, keep auth and logging in shared plugins so new route files cannot forget them.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="should-i-use-onrequest-or-prehandler-for-auth-in-fastify">Should I use onRequest or preHandler for auth in Fastify? <a class="post-heading-anchor" href="#should-i-use-onrequest-or-prehandler-for-auth-in-fastify" aria-hidden="true">#</a></h3>
<p>Use <code>onRequest</code> for authentication, since it only needs headers and rejects early. Use <code>preHandler</code> for authorization that needs params, the body or a database lookup.</p>
<h3 id="how-do-i-get-the-response-time-in-fastify-v5">How do I get the response time in Fastify v5? <a class="post-heading-anchor" href="#how-do-i-get-the-response-time-in-fastify-v5" aria-hidden="true">#</a></h3>
<p>Read <code>reply.elapsedTime</code> in <code>onResponse</code>. It is in milliseconds. <code>reply.getResponseTime()</code> was removed in v5.</p>
<h3 id="why-does-my-fastify-hook-run-twice">Why does my Fastify hook run twice? <a class="post-heading-anchor" href="#why-does-my-fastify-hook-run-twice" aria-hidden="true">#</a></h3>
<p>Usually because it mixes styles: it is <code>async</code> and also calls <code>done</code>, or it sends a reply outside the promise chain without returning <code>reply</code>. Pick one style and return <code>reply</code> after sending.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not maintain the <code>onResponse</code> logging yourself, <a href="https://www.apitraffic.io/docs/integrations/fastify">register <code>apitraffic.fastify()</code> as the first plugin</a> and it captures every request and response, including headers and bodies, under the matched route pattern. Credential headers are <a href="https://www.apitraffic.io/docs/data/redactions">masked by default</a>.</p>
<p>Keep your auth hook, and add one line after you identify the user, so requests rejected later by a <code>preHandler</code> still carry it:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">apitraffic.<span class="hljs-title">tag</span>(<span class="hljs-string">'userId'</span>, <span class="hljs-title">String</span>(user.<span class="hljs-property">id</span>));
</code></pre></div>
<p>The <a href="https://www.apitraffic.io/docs/waf/overview">firewall</a> runs in the same plugin as a <code>preHandler</code> hook, so rate limits and access lists apply before your handler without another hook of your own.</p>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260420-fastify-hooks-logging-auth">apitraffic.io</a></p>
<p>Sources: <a href="https://fastify.dev/docs/latest/Reference/Hooks/" target="_blank" rel="noopener noreferrer">Fastify docs: Hooks</a>; <a href="https://fastify.dev/docs/latest/Guides/Migration-Guide-V5/" target="_blank" rel="noopener noreferrer">Fastify docs: V5 Migration Guide</a>; <a href="https://fastify.dev/docs/latest/Reference/Encapsulation/" target="_blank" rel="noopener noreferrer">Fastify docs: Encapsulation</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>BOLA vulnerability in Node APIs: how to fix IDOR and catch it in your logs</title>
      <link>https://www.apitraffic.io/blog/bola-vulnerability-node-api</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/bola-vulnerability-node-api</guid>
      <pubDate>Mon, 13 Apr 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>Change one digit in a URL and get another customer&apos;s invoice. Here is how to fix BOLA and IDOR structurally and spot someone probing for it before they find a gap.</description>
      <content:encoded><![CDATA[<p>The email arrives on a Friday: a customer changed <code>/invoices/1041</code> to <code>/invoices/1042</code> in their browser and got someone else’s invoice. That is a BOLA vulnerability, the API bug scanners and bored attackers try first on every API they find.</p>
<p>The short answer: a BOLA vulnerability happens when an endpoint loads an object by the ID the client sent without checking that the caller may access it. The fix is to put the ownership or tenant condition into the database query itself, return <code>404</code> for objects the caller cannot see, and add a cross-tenant test for every route that takes an ID. Then watch your logs for callers touching many distinct IDs.</p>
<h2 id="what-is-a-bola-vulnerability">What is a BOLA vulnerability? <a class="post-heading-anchor" href="#what-is-a-bola-vulnerability" aria-hidden="true">#</a></h2>
<p>BOLA (broken object level authorization) is the API name for IDOR (insecure direct object reference). OWASP ranks it <a href="https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/" target="_blank" rel="noopener noreferrer">API1:2023</a>, first in the current API Security Top 10. Authentication is usually fine in these cases. The handler checks “who are you” and forgets “is this yours”.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// Express 5. Authenticated, and still broken.</span>
app.<span class="hljs-title">get</span>(<span class="hljs-string">'/v1/invoices/:id'</span>, requireAuth, <span class="hljs-title">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> invoice = <span class="hljs-keyword">await</span> db.<span class="hljs-property">invoice</span>.<span class="hljs-title">findUnique</span>({ <span class="hljs-attr">where</span>: { <span class="hljs-attr">id</span>: req.<span class="hljs-property">params</span>.<span class="hljs-property">id</span> } });
  <span class="hljs-keyword">if</span> (!invoice) <span class="hljs-keyword">return</span> res.<span class="hljs-title">status</span>(<span class="hljs-number">404</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'not_found'</span> });
  res.<span class="hljs-title">json</span>(invoice);
});
</code></pre></div>
<p>The same mistake in other shapes:</p>
<ul>
<li>The list endpoint filters by tenant, but detail, update and delete do not.</li>
<li>The ID comes from the body (<code>{ "accountId": 77 }</code> in a PATCH) instead of the path.</li>
<li>A nested route checks the parent but loads the child by its own ID: <code>/projects/:projectId/files/:fileId</code>.</li>
<li>A bulk endpoint checks only the first ID in an array.</li>
<li>A new API version or admin route skips the middleware the others use. Route rewrites during an <a href="/blog/express-5-migration">Express 5 migration</a> are a common moment for this.</li>
</ul>
<h2 id="why-scanners-find-idor-so-easily">Why scanners find IDOR so easily <a class="post-heading-anchor" href="#why-scanners-find-idor-so-easily" aria-hidden="true">#</a></h2>
<p>BOLA is cheap to test. Sequential IDs make enumeration trivial, success is a <code>200</code> where a <code>404</code> belongs, and the payoff is real data. Two accounts and a script test every route that takes an ID. UUIDs make guessing harder, but IDs leak through URLs, exports, emails and other responses, so random IDs are a speed bump, not an authorization check.</p>
<h2 id="how-to-fix-broken-object-level-authorization-in-node-js">How to fix broken object level authorization in Node.js <a class="post-heading-anchor" href="#how-to-fix-broken-object-level-authorization-in-node-js" aria-hidden="true">#</a></h2>
<h3 id="step-1-scope-the-query">Step 1: scope the query <a class="post-heading-anchor" href="#step-1-scope-the-query" aria-hidden="true">#</a></h3>
<p>Put the ownership condition in the query, so the object cannot load without it:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">app.<span class="hljs-title">get</span>(<span class="hljs-string">'/v1/invoices/:id'</span>, requireAuth, <span class="hljs-title">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> invoice = <span class="hljs-keyword">await</span> db.<span class="hljs-property">invoice</span>.<span class="hljs-title">findFirst</span>({
    <span class="hljs-attr">where</span>: { <span class="hljs-attr">id</span>: req.<span class="hljs-property">params</span>.<span class="hljs-property">id</span>, <span class="hljs-attr">accountId</span>: req.<span class="hljs-property">user</span>.<span class="hljs-property">accountId</span> },
  });
  <span class="hljs-keyword">if</span> (!invoice) <span class="hljs-keyword">return</span> res.<span class="hljs-title">status</span>(<span class="hljs-number">404</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'not_found'</span> });
  res.<span class="hljs-title">json</span>(invoice);
});
</code></pre></div>
<h3 id="step-2-make-the-scoped-path-the-only-path">Step 2: make the scoped path the only path <a class="post-heading-anchor" href="#step-2-make-the-scoped-path-the-only-path" aria-hidden="true">#</a></h3>
<p>A small repository layer that requires a scope removes the chance to forget:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// invoices.repo.js</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">invoicesFor</span>(<span class="hljs-params">scope</span>) {
  <span class="hljs-keyword">if</span> (!scope?.<span class="hljs-property">accountId</span>) <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title">Error</span>(<span class="hljs-string">'invoicesFor requires an account scope'</span>);
  <span class="hljs-keyword">const</span> base = { <span class="hljs-attr">accountId</span>: scope.<span class="hljs-property">accountId</span> };
  <span class="hljs-keyword">return</span> {
    <span class="hljs-attr">get</span>: <span class="hljs-function">(<span class="hljs-params">id</span>) =&gt;</span> db.<span class="hljs-property">invoice</span>.<span class="hljs-title">findFirst</span>({ <span class="hljs-attr">where</span>: { ...base, id } }),
    <span class="hljs-attr">list</span>: <span class="hljs-function">(<span class="hljs-params">opts = {}</span>) =&gt;</span> db.<span class="hljs-property">invoice</span>.<span class="hljs-title">findMany</span>({ <span class="hljs-attr">where</span>: base, ...opts }),
    <span class="hljs-attr">update</span>: <span class="hljs-function">(<span class="hljs-params">id, data</span>) =&gt;</span> db.<span class="hljs-property">invoice</span>.<span class="hljs-title">updateMany</span>({ <span class="hljs-attr">where</span>: { ...base, id }, data }),
  };
}
</code></pre></div>
<p><code>updateMany</code> with the scope in <code>where</code> returns a count. Zero means the object does not exist or is not the caller’s, and you return <code>404</code> either way, which avoids confirming the ID exists. Use <code>403</code> when the caller can see the object but not perform the action, like a viewer trying to delete.</p>
<h3 id="step-3-in-fastify-load-the-resource-in-a-prehandler">Step 3: in Fastify, load the resource in a preHandler <a class="post-heading-anchor" href="#step-3-in-fastify-load-the-resource-in-a-prehandler" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> <span class="hljs-title">Fastify</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'fastify'</span>;
<span class="hljs-keyword">const</span> app = <span class="hljs-title">Fastify</span>();
app.<span class="hljs-title">decorateRequest</span>(<span class="hljs-string">'invoice'</span>, <span class="hljs-literal">null</span>);

<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title">loadInvoice</span>(<span class="hljs-params">request, reply</span>) {
  <span class="hljs-keyword">const</span> invoice = <span class="hljs-keyword">await</span> <span class="hljs-title">invoicesFor</span>(request.<span class="hljs-property">user</span>).<span class="hljs-title">get</span>(request.<span class="hljs-property">params</span>.<span class="hljs-property">id</span>);
  <span class="hljs-keyword">if</span> (!invoice) <span class="hljs-keyword">return</span> reply.<span class="hljs-title">code</span>(<span class="hljs-number">404</span>).<span class="hljs-title">send</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'not_found'</span> });
  request.<span class="hljs-property">invoice</span> = invoice;
}

app.<span class="hljs-title">get</span>(<span class="hljs-string">'/v1/invoices/:id'</span>, { <span class="hljs-attr">preHandler</span>: [authenticate, loadInvoice] },
  <span class="hljs-title">async</span> (request) =&gt; request.<span class="hljs-property">invoice</span>);
</code></pre></div>
<p>In <a href="https://www.apitraffic.io/nestjs">NestJS</a> the same idea fits a guard, but the scoped query is what makes it hold.</p>
<h3 id="step-4-test-it-like-an-attacker">Step 4: test it like an attacker <a class="post-heading-anchor" href="#step-4-test-it-like-an-attacker" aria-hidden="true">#</a></h3>
<p>Single-user unit tests rarely catch BOLA. Add a cross-tenant test per route:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> { test } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:test'</span>;
<span class="hljs-keyword">import</span> assert <span class="hljs-keyword">from</span> <span class="hljs-string">'node:assert/strict'</span>;

<span class="hljs-title">test</span>(<span class="hljs-string">'account B cannot read account A invoice'</span>, <span class="hljs-title">async</span> () =&gt; {
  <span class="hljs-keyword">const</span> a = <span class="hljs-keyword">await</span> <span class="hljs-title">createAccountWithInvoice</span>();
  <span class="hljs-keyword">const</span> b = <span class="hljs-keyword">await</span> <span class="hljs-title">createAccount</span>();
  <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title">fetch</span>(<span class="hljs-string">`<span class="hljs-subst">${BASE}</span>/v1/invoices/<span class="hljs-subst">${a.invoiceId}</span>`</span>, {
    <span class="hljs-attr">headers</span>: { <span class="hljs-attr">authorization</span>: <span class="hljs-string">`Bearer <span class="hljs-subst">${b.token}</span>`</span> },
  });
  assert.<span class="hljs-title">equal</span>(res.<span class="hljs-property">status</span>, <span class="hljs-number">404</span>);
});
</code></pre></div>
<p>Generate the route list from your router so a new route without a cross-tenant test fails CI.</p>
<h2 id="how-to-detect-bola-attacks-in-request-logs">How to detect BOLA attacks in request logs <a class="post-heading-anchor" href="#how-to-detect-bola-attacks-in-request-logs" aria-hidden="true">#</a></h2>
<p>Probing has a recognizable shape:</p>
<ol>
<li>One caller, many distinct object IDs on one route pattern.</li>
<li>Sequential IDs in order from the same token.</li>
<li>A high share of <code>403</code> or <code>404</code> responses on <code>/:id</code> routes for one caller.</li>
<li>Mostly <code>404</code>s, then a run of <code>200</code>s. A scanner found a route without a check. This is the one to alert on.</li>
<li>IDs requested that never appeared in that caller’s list responses.</li>
</ol>
<p>Your logs need caller identity and route pattern, not just the raw URL. A quick offline check over exported JSON lines:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// enum-check.mjs: node enum-check.mjs requests.jsonl</span>
<span class="hljs-keyword">import</span> { createReadStream } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:fs'</span>;
<span class="hljs-keyword">import</span> { createInterface } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:readline'</span>;

<span class="hljs-keyword">const</span> byCaller = <span class="hljs-keyword">new</span> <span class="hljs-title">Map</span>();
<span class="hljs-keyword">const</span> rl = <span class="hljs-title">createInterface</span>({ <span class="hljs-attr">input</span>: <span class="hljs-title">createReadStream</span>(process.<span class="hljs-property">argv</span>[<span class="hljs-number">2</span>]) });

<span class="hljs-keyword">for</span> <span class="hljs-title">await</span> (<span class="hljs-keyword">const</span> line <span class="hljs-keyword">of</span> rl) {
  <span class="hljs-keyword">const</span> r = <span class="hljs-title">JSON</span>.<span class="hljs-title">parse</span>(line); <span class="hljs-comment">// { userId, route, objectId, status }</span>
  <span class="hljs-keyword">if</span> (!r.<span class="hljs-property">objectId</span>) <span class="hljs-keyword">continue</span>;
  <span class="hljs-keyword">const</span> key = <span class="hljs-string">`<span class="hljs-subst">${r.userId}</span> <span class="hljs-subst">${r.route}</span>`</span>;
  <span class="hljs-keyword">const</span> s = byCaller.<span class="hljs-title">get</span>(key) ?? { <span class="hljs-attr">ids</span>: <span class="hljs-keyword">new</span> <span class="hljs-title">Set</span>(), <span class="hljs-attr">denied</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">ok</span>: <span class="hljs-number">0</span> };
  s.<span class="hljs-property">ids</span>.<span class="hljs-title">add</span>(r.<span class="hljs-property">objectId</span>);
  <span class="hljs-keyword">if</span> (r.<span class="hljs-property">status</span> === <span class="hljs-number">403</span> || r.<span class="hljs-property">status</span> === <span class="hljs-number">404</span>) s.<span class="hljs-property">denied</span>++;
  <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (r.<span class="hljs-property">status</span> &lt; <span class="hljs-number">300</span>) s.<span class="hljs-property">ok</span>++;
  byCaller.<span class="hljs-title">set</span>(key, s);
}
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [key, s] <span class="hljs-keyword">of</span> byCaller) {
  <span class="hljs-keyword">if</span> (s.<span class="hljs-property">ids</span>.<span class="hljs-property">size</span> &gt; <span class="hljs-number">50</span>) <span class="hljs-variable">console</span>.<span class="hljs-title">log</span>(key, { <span class="hljs-attr">distinct</span>: s.<span class="hljs-property">ids</span>.<span class="hljs-property">size</span>, <span class="hljs-attr">denied</span>: s.<span class="hljs-property">denied</span>, <span class="hljs-attr">ok</span>: s.<span class="hljs-property">ok</span> });
}
</code></pre></div>
<p>Tune the threshold. Admin users who touch thousands of records need an exception, which is a good reason to tag callers by role. The same distinct-accounts idea powers <a href="/blog/credential-stuffing-detection">credential stuffing detection</a>.</p>
<p>For a one-person team, the afternoon version is scoping every <code>/:id</code> query and running this script weekly. A ten-person team should add the repository layer, generated cross-tenant tests in CI and an alert on pattern 4.</p>
<h2 id="bola-checklist-for-every-route-that-takes-an-id">BOLA checklist for every route that takes an ID <a class="post-heading-anchor" href="#bola-checklist-for-every-route-that-takes-an-id" aria-hidden="true">#</a></h2>
<p>If you only do one thing, grep for <code>findUnique</code>, <code>findById</code> and <code>WHERE id =</code> calls that do not include a tenant or owner condition, and fix those first. Then confirm:</p>
<ul>
<li>The ownership condition is in the query, not an <code>if</code> after it.</li>
<li>Body and query-string IDs get the same check as path IDs.</li>
<li>Nested resources check the child.</li>
<li>Bulk endpoints check every ID.</li>
<li>Invisible objects return <code>404</code>.</li>
<li>A cross-tenant test exists.</li>
<li>Logs record caller, route pattern and object ID.</li>
</ul>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="what-is-the-difference-between-bola-and-idor">What is the difference between BOLA and IDOR? <a class="post-heading-anchor" href="#what-is-the-difference-between-bola-and-idor" aria-hidden="true">#</a></h3>
<p>They describe the same flaw. IDOR is the older web application term, and BOLA is the name OWASP uses in the API Security Top 10, where it is API1:2023.</p>
<h3 id="do-uuids-prevent-idor-vulnerabilities">Do UUIDs prevent IDOR vulnerabilities? <a class="post-heading-anchor" href="#do-uuids-prevent-idor-vulnerabilities" aria-hidden="true">#</a></h3>
<p>No. They make IDs harder to guess, but IDs leak through links, logs and other responses. Authorization still has to be checked on every request.</p>
<h3 id="should-an-unauthorized-object-return-403-or-404">Should an unauthorized object return 403 or 404? <a class="post-heading-anchor" href="#should-an-unauthorized-object-return-403-or-404" aria-hidden="true">#</a></h3>
<p>Return <code>404</code> when the caller should not know the object exists, which is the usual case across tenants. Return <code>403</code> when they can see it but lack permission for the action.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not build enumeration detection yourself, ApiTraffic covers the logging and the response.</p>
<ul>
<li>Requests are recorded under the <a href="https://www.apitraffic.io/docs/endpoint-handling">matched route pattern</a> (for example <code>/api/v1/invoices/{id}</code>), and <code>apitraffic.tag('userId', String(req.user.id))</code> <a href="https://www.apitraffic.io/docs/tags">ties each one to a user</a>. Tags stick even when a guard rejects the request.</li>
<li>Review one caller with <code>tag:userId == "1042" AND status_code &gt;= 400</code>.</li>
<li><a href="https://www.apitraffic.io/docs/waf/overview">Behavioral protection</a> scores each caller on distinct object IDs per endpoint, 401/403 counts, 404 counts and distinct endpoints, and gives callers over the threshold a temporary deny on every server. Monitor mode shows who would be denied first.</li>
</ul>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260413-bola-vulnerability-node-api">apitraffic.io</a></p>
<p>Sources: <a href="https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/" target="_blank" rel="noopener noreferrer">OWASP API Security Top 10 2023, API1:2023 Broken Object Level Authorization</a>; <a href="https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html" target="_blank" rel="noopener noreferrer">OWASP Authorization Cheat Sheet</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>How to find N+1 queries in a Node.js API before your users do</title>
      <link>https://www.apitraffic.io/blog/n-plus-1-queries-node</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/n-plus-1-queries-node</guid>
      <pubDate>Fri, 03 Apr 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>An endpoint that&apos;s fast in development takes 900 ms for your biggest customer because it runs 201 queries. Find N+1 queries in a Node API with a per-request query counter and a test, then fix them with one batched query.</description>
      <content:encoded><![CDATA[<p>The orders endpoint took 40 ms in development and takes 900 ms for your biggest customer. Nothing is wrong with the database; it’s answering 201 queries for one request. That pattern, one query for a list and one more for each item in it, is the N+1 query problem, and it’s the most common reason a Node API gets slower as customers grow.</p>
<p>To find N+1 queries, count database queries per request and flag requests where the same query shape runs many times. You can do that with <code>AsyncLocalStorage</code> and a thin wrapper around your database client in about an hour. To fix them, replace the per-item query with one batched query: a <code>JOIN</code>, a <code>WHERE id = ANY($1)</code>, an ORM <code>include</code>, or DataLoader.</p>
<h2 id="what-n-1-queries-look-like-in-express">What N+1 queries look like in Express <a class="post-heading-anchor" href="#what-n-1-queries-look-like-in-express" aria-hidden="true">#</a></h2>
<p>A typical example with <code>node-postgres</code>:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">app.<span class="hljs-title">get</span>(<span class="hljs-string">'/orders'</span>, <span class="hljs-title">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> { <span class="hljs-attr">rows</span>: orders } = <span class="hljs-keyword">await</span> db.<span class="hljs-title">query</span>(
    <span class="hljs-string">'select id, customer_id, total from orders where account_id = $1 order by id desc limit 200'</span>,
    [req.<span class="hljs-property">user</span>.<span class="hljs-property">accountId</span>],
  );
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> order <span class="hljs-keyword">of</span> orders) {
    <span class="hljs-keyword">const</span> { rows } = <span class="hljs-keyword">await</span> db.<span class="hljs-title">query</span>(<span class="hljs-string">'select name, email from customers where id = $1'</span>, [order.<span class="hljs-property">customer_id</span>]);
    order.<span class="hljs-property">customer</span> = rows[<span class="hljs-number">0</span>];
  }
  res.<span class="hljs-title">json</span>(orders);
});
</code></pre></div>
<p>With 10 orders in your seed data, that’s 11 queries at maybe 0.3 ms each against a local database: invisible. With 200 orders and a managed database 1 to 2 ms away, it’s 201 round trips and several hundred milliseconds, all spent waiting on the network.</p>
<h2 id="why-n-1-queries-hide-until-production">Why N+1 queries hide until production <a class="post-heading-anchor" href="#why-n-1-queries-hide-until-production" aria-hidden="true">#</a></h2>
<ul>
<li><strong>Seed data is small.</strong> N is 5 locally and 500 for your largest account.</li>
<li><strong>Local databases are close.</strong> Network round-trip time dominates each query in production, and it’s near zero on localhost.</li>
<li><strong>Averages smooth it out.</strong> Most accounts are small, so mean latency barely moves. The slowdown shows up in p95 and p99, which is why <a href="/blog/api-latency-percentiles">reading latency percentiles</a> properly matters here.</li>
<li><strong>ORMs hide the loop.</strong> A lazy-loaded relation or a resolver per field issues queries you never wrote.</li>
</ul>
<h2 id="how-to-detect-n-1-queries-per-request">How to detect N+1 queries per request <a class="post-heading-anchor" href="#how-to-detect-n-1-queries-per-request" aria-hidden="true">#</a></h2>
<h3 id="step-1-track-queries-in-request-context">Step 1: track queries in request context <a class="post-heading-anchor" href="#step-1-track-queries-in-request-context" aria-hidden="true">#</a></h3>
<p>If you already have <a href="/blog/asynclocalstorage-request-id">request context with AsyncLocalStorage</a>, add a query list to the store. Otherwise this is the minimum:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// query-tracking.js</span>
<span class="hljs-keyword">import</span> { <span class="hljs-title">AsyncLocalStorage</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:async_hooks'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> queryContext = <span class="hljs-keyword">new</span> <span class="hljs-title">AsyncLocalStorage</span>();

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">trackQueries</span>(<span class="hljs-params">req, res, next</span>) {
  <span class="hljs-keyword">const</span> store = { <span class="hljs-attr">queries</span>: <span class="hljs-keyword">new</span> <span class="hljs-title">Map</span>(), <span class="hljs-attr">total</span>: <span class="hljs-number">0</span> };
  res.<span class="hljs-title">on</span>(<span class="hljs-string">'finish'</span>, <span class="hljs-function">() =&gt;</span> <span class="hljs-title">report</span>(req, res, store));
  queryContext.<span class="hljs-title">run</span>(store, next);
}

<span class="hljs-keyword">function</span> <span class="hljs-title">normalize</span>(<span class="hljs-params">sql</span>) {
  <span class="hljs-keyword">return</span> sql.<span class="hljs-title">replace</span>(<span class="hljs-regexp">/\s+/g</span>, <span class="hljs-string">' '</span>).<span class="hljs-title">replace</span>(<span class="hljs-regexp">/'[^']*'/g</span>, <span class="hljs-string">'?'</span>).<span class="hljs-title">replace</span>(<span class="hljs-regexp">/\b\d+\b/g</span>, <span class="hljs-string">'?'</span>).<span class="hljs-title">trim</span>();
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">recordQuery</span>(<span class="hljs-params">sql</span>) {
  <span class="hljs-keyword">const</span> store = queryContext.<span class="hljs-title">getStore</span>();
  <span class="hljs-keyword">if</span> (!store) <span class="hljs-keyword">return</span>;
  <span class="hljs-keyword">const</span> key = <span class="hljs-title">normalize</span>(sql);
  store.<span class="hljs-property">queries</span>.<span class="hljs-title">set</span>(key, (store.<span class="hljs-property">queries</span>.<span class="hljs-title">get</span>(key) ?? <span class="hljs-number">0</span>) + <span class="hljs-number">1</span>);
  store.<span class="hljs-property">total</span> += <span class="hljs-number">1</span>;
}
</code></pre></div>
<h3 id="step-2-wrap-your-database-client">Step 2: wrap your database client <a class="post-heading-anchor" href="#step-2-wrap-your-database-client" aria-hidden="true">#</a></h3>
<p>Route every query through one function so tracking can’t be skipped:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// db.js</span>
<span class="hljs-keyword">import</span> pg <span class="hljs-keyword">from</span> <span class="hljs-string">'pg'</span>;
<span class="hljs-keyword">import</span> { recordQuery } <span class="hljs-keyword">from</span> <span class="hljs-string">'./query-tracking.js'</span>;

<span class="hljs-keyword">const</span> pool = <span class="hljs-keyword">new</span> pg.<span class="hljs-title">Pool</span>({ <span class="hljs-attr">connectionString</span>: process.<span class="hljs-property">env</span>.<span class="hljs-property">DATABASE_URL</span> });

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> db = {
  <span class="hljs-title">query</span>(<span class="hljs-params">text, params</span>) {
    <span class="hljs-title">recordQuery</span>(text);
    <span class="hljs-keyword">return</span> pool.<span class="hljs-title">query</span>(text, params);
  },
};
</code></pre></div>
<h3 id="step-3-log-suspicious-requests">Step 3: log suspicious requests <a class="post-heading-anchor" href="#step-3-log-suspicious-requests" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// query-tracking.js (continued)</span>
<span class="hljs-keyword">import</span> { logger } <span class="hljs-keyword">from</span> <span class="hljs-string">'./logger.js'</span>;

<span class="hljs-keyword">const</span> <span class="hljs-variable">REPEAT_THRESHOLD</span> = <span class="hljs-number">10</span>;
<span class="hljs-keyword">const</span> <span class="hljs-variable">TOTAL_THRESHOLD</span> = <span class="hljs-number">50</span>;

<span class="hljs-keyword">function</span> <span class="hljs-title">report</span>(<span class="hljs-params">req, res, store</span>) {
  <span class="hljs-keyword">const</span> repeated = [...store.<span class="hljs-property">queries</span>].<span class="hljs-title">filter</span>(<span class="hljs-function">(<span class="hljs-params">[, n]</span>) =&gt;</span> n &gt;= <span class="hljs-variable">REPEAT_THRESHOLD</span>);
  <span class="hljs-keyword">if</span> (repeated.<span class="hljs-property">length</span> === <span class="hljs-number">0</span> &amp;&amp; store.<span class="hljs-property">total</span> &lt; <span class="hljs-variable">TOTAL_THRESHOLD</span>) <span class="hljs-keyword">return</span>;
  logger.<span class="hljs-title">warn</span>({
    <span class="hljs-attr">route</span>: req.<span class="hljs-property">route</span>?.<span class="hljs-property">path</span>,
    <span class="hljs-attr">method</span>: req.<span class="hljs-property">method</span>,
    <span class="hljs-attr">status</span>: res.<span class="hljs-property">statusCode</span>,
    <span class="hljs-attr">totalQueries</span>: store.<span class="hljs-property">total</span>,
    <span class="hljs-attr">repeated</span>: repeated.<span class="hljs-title">map</span>(<span class="hljs-function">(<span class="hljs-params">[sql, n]</span>) =&gt;</span> ({ <span class="hljs-attr">sql</span>: sql.<span class="hljs-title">slice</span>(<span class="hljs-number">0</span>, <span class="hljs-number">200</span>), n })),
  }, <span class="hljs-string">'possible N+1'</span>);
}
</code></pre></div>
<p>Register <code>app.use(trackQueries)</code> before your routes. Log the route pattern, not the raw URL, so <code>/orders</code> for every account groups into one line. A day of these warnings gives you a ranked list of endpoints to fix.</p>
<h2 id="using-your-orms-query-log">Using your ORM’s query log <a class="post-heading-anchor" href="#using-your-orms-query-log" aria-hidden="true">#</a></h2>
<p>If you use an ORM, its query log gives you the same signal:</p>
<div class="post-table"><table>
<thead>
<tr>
<th>Tool</th>
<th>How to see queries</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://www.prisma.io/docs/orm/prisma-client/observability-and-logging/logging" target="_blank" rel="noopener noreferrer">Prisma</a></td>
<td><code>new PrismaClient({ log: [{ emit: 'event', level: 'query' }] })</code> then <code>prisma.$on('query', (e) =&gt; recordQuery(e.query))</code></td>
</tr>
<tr>
<td>Sequelize</td>
<td><code>logging: (sql) =&gt; recordQuery(sql)</code> in the constructor options</td>
</tr>
<tr>
<td>Knex</td>
<td><code>knex.on('query', (q) =&gt; recordQuery(q.sql))</code></td>
</tr>
<tr>
<td>TypeORM</td>
<td>a custom logger implementing <code>logQuery</code></td>
</tr>
</tbody>
</table></div>
<p>Hooking those into <code>recordQuery</code> means the same per-request report works regardless of ORM.</p>
<h2 id="catch-n-1-queries-in-tests">Catch N+1 queries in tests <a class="post-heading-anchor" href="#catch-n-1-queries-in-tests" aria-hidden="true">#</a></h2>
<p>Production detection finds the ones you already shipped. A test stops new ones:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> { test } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:test'</span>;
<span class="hljs-keyword">import</span> assert <span class="hljs-keyword">from</span> <span class="hljs-string">'node:assert/strict'</span>;
<span class="hljs-keyword">import</span> request <span class="hljs-keyword">from</span> <span class="hljs-string">'supertest'</span>;
<span class="hljs-keyword">import</span> { app } <span class="hljs-keyword">from</span> <span class="hljs-string">'../app.js'</span>;
<span class="hljs-keyword">import</span> { seedOrders } <span class="hljs-keyword">from</span> <span class="hljs-string">'./seed.js'</span>;
<span class="hljs-keyword">import</span> { lastRequestStats } <span class="hljs-keyword">from</span> <span class="hljs-string">'../query-tracking.js'</span>;

<span class="hljs-title">test</span>(<span class="hljs-string">'GET /orders does not scale queries with order count'</span>, <span class="hljs-title">async</span> () =&gt; {
  <span class="hljs-keyword">await</span> <span class="hljs-title">seedOrders</span>({ <span class="hljs-attr">count</span>: <span class="hljs-number">50</span> });
  <span class="hljs-keyword">await</span> <span class="hljs-title">request</span>(app).<span class="hljs-title">get</span>(<span class="hljs-string">'/orders'</span>).<span class="hljs-title">set</span>(<span class="hljs-string">'authorization'</span>, <span class="hljs-string">'Bearer test'</span>).<span class="hljs-title">expect</span>(<span class="hljs-number">200</span>);
  assert.<span class="hljs-title">ok</span>(<span class="hljs-title">lastRequestStats</span>().<span class="hljs-property">total</span> &lt;= <span class="hljs-number">3</span>, <span class="hljs-string">`ran <span class="hljs-subst">${lastRequestStats().total}</span> queries`</span>);
});
</code></pre></div>
<p><code>lastRequestStats()</code> is a small test-only export that keeps the most recent store. Seed more rows than a developer would by hand; an N+1 is obvious at 50 and invisible at 3.</p>
<h2 id="how-to-fix-n-1-queries">How to fix N+1 queries <a class="post-heading-anchor" href="#how-to-fix-n-1-queries" aria-hidden="true">#</a></h2>
<p>Fetch the related rows in one query, then join in memory:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">const</span> { <span class="hljs-attr">rows</span>: orders } = <span class="hljs-keyword">await</span> db.<span class="hljs-title">query</span>(
  <span class="hljs-string">'select id, customer_id, total from orders where account_id = $1 order by id desc limit 200'</span>,
  [req.<span class="hljs-property">user</span>.<span class="hljs-property">accountId</span>],
);
<span class="hljs-keyword">const</span> ids = [...<span class="hljs-keyword">new</span> <span class="hljs-title">Set</span>(orders.<span class="hljs-title">map</span>(<span class="hljs-function">(<span class="hljs-params">o</span>) =&gt;</span> o.<span class="hljs-property">customer_id</span>))];
<span class="hljs-keyword">const</span> { <span class="hljs-attr">rows</span>: customers } = <span class="hljs-keyword">await</span> db.<span class="hljs-title">query</span>(
  <span class="hljs-string">'select id, name, email from customers where id = any($1)'</span>,
  [ids],
);
<span class="hljs-keyword">const</span> byId = <span class="hljs-keyword">new</span> <span class="hljs-title">Map</span>(customers.<span class="hljs-title">map</span>(<span class="hljs-function">(<span class="hljs-params">c</span>) =&gt;</span> [c.<span class="hljs-property">id</span>, c]));
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> o <span class="hljs-keyword">of</span> orders) o.<span class="hljs-property">customer</span> = byId.<span class="hljs-title">get</span>(o.<span class="hljs-property">customer_id</span>);
</code></pre></div>
<p>Two queries instead of 201, whatever the account size. <code>node-postgres</code> <a href="https://node-postgres.com/features/queries" target="_blank" rel="noopener noreferrer">converts the JavaScript array to a Postgres array</a> for <code>any($1)</code>. Other options:</p>
<ul>
<li><strong>A <code>JOIN</code></strong> when you always need the related data and the result set stays small.</li>
<li><strong>ORM eager loading</strong>, such as Prisma <code>include</code> or Sequelize <code>include</code>, instead of lazy access inside a loop.</li>
<li><strong>DataLoader</strong> for GraphQL resolvers or deeply nested code, where per-item lookups are hard to avoid. It <a href="https://github.com/graphql/dataloader" target="_blank" rel="noopener noreferrer">batches all <code>load(id)</code> calls in one tick</a> into a single fetch.</li>
</ul>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<p>On a 1-person team: add the per-request query counter and read the warnings after a day of traffic. Fix the top endpoint. On a 10-person team: also add the query-count test to the endpoints that list things, so the next N+1 fails in CI instead of in production.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="what-is-an-n-1-query">What is an N+1 query? <a class="post-heading-anchor" href="#what-is-an-n-1-query" aria-hidden="true">#</a></h3>
<p>One query loads a list of N rows, then N more queries load related data for each row. The total grows with the size of the list, so it gets slower as customers grow.</p>
<h3 id="does-prisma-cause-n-1-queries">Does Prisma cause N+1 queries? <a class="post-heading-anchor" href="#does-prisma-cause-n-1-queries" aria-hidden="true">#</a></h3>
<p><code>include</code> loads relations with a fixed number of queries rather than one per row. N+1 appears when you call <code>findUnique</code> inside a loop or resolve relations per item in GraphQL resolvers.</p>
<h3 id="how-many-queries-per-request-is-too-many">How many queries per request is too many? <a class="post-heading-anchor" href="#how-many-queries-per-request-is-too-many" aria-hidden="true">#</a></h3>
<p>There’s no universal number, but a count that grows with result size is the warning sign. A list endpoint that runs the same query shape 10 or more times per request is worth a look.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you’d rather not build query tracking yourself, run your app with the <a href="https://www.apitraffic.io/docs/apm/setup">APM preload</a>, <code>node -r @apitraffic/node/register ./server.js</code>, so OpenTelemetry instruments your database driver before it loads. Each query is <a href="https://www.apitraffic.io/docs/apm/overview">recorded as a span and attributed to the request</a> that issued it.</p>
<p><a href="https://www.apitraffic.io/docs/apm/analytics">Top Operations</a> ranks queries by calls per request and flags N+1 patterns, with the code location that issued them. The request waterfall shows every query on the request’s timeline, so 200 identical rows stand out at a glance, and the endpoint’s Time Breakdown shows how much of its time goes to the database.</p>
<p>Start free and find the N+1 queries in your API: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260403-n-plus-1-queries-node">apitraffic.io</a></p>
<p>Sources: <a href="https://node-postgres.com/features/queries" target="_blank" rel="noopener noreferrer">node-postgres docs: queries</a>; <a href="https://www.prisma.io/docs/orm/prisma-client/observability-and-logging/logging" target="_blank" rel="noopener noreferrer">Prisma docs: logging</a>; <a href="https://github.com/graphql/dataloader" target="_blank" rel="noopener noreferrer">DataLoader README</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Express 5 migration: what breaks in your API and how to fix it</title>
      <link>https://www.apitraffic.io/blog/express-5-migration</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/express-5-migration</guid>
      <pubDate>Mon, 16 Mar 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>You bump Express to 5 and the app dies with &quot;Missing parameter name&quot;, or worse, starts fine and quietly breaks pagination. Here is what the Express 5 migration actually breaks in an API and how to fix each one.</description>
      <content:encoded><![CDATA[<p>You bump <code>express</code> to <code>^5</code>, run the app, and it crashes before serving a request with <code>Missing parameter name at index 2: /*</code>. Or worse, it starts fine and a <a href="/blog/cursor-vs-offset-pagination">pagination middleware</a> silently stops working. This Express 5 migration guide covers the changes that actually break production APIs, how to find each one in your codebase, and the fix.</p>
<p>The direct answer: Express 5 needs Node 18 or newer, changes route path syntax (named wildcards, no <code>?</code> or regex in strings), makes <code>req.query</code> read-only, leaves <code>req.body</code> undefined when nothing parsed it, validates <code>res.status()</code>, removes old method signatures, and forwards rejected promises to your error handler. Most APIs migrate in an afternoon; the route syntax is where the time goes.</p>
<h2 id="why-migrate-to-express-5-now">Why migrate to Express 5 now? <a class="post-heading-anchor" href="#why-migrate-to-express-5-now" aria-hidden="true">#</a></h2>
<p>Express 5.0 shipped in September 2024 after a decade on 4.x, <a href="https://expressjs.com/en/blog/2025-03-31-v5-1-latest-release" target="_blank" rel="noopener noreferrer">5.1 followed in March 2025 and became the <code>latest</code> tag on npm</a>, and the current release is 5.2.1. Express 4 moved to maintenance (security and high-priority fixes only) on April 1, 2025, and the Express team’s proposed schedule puts its end of life no sooner than October 1, 2026. Not an emergency, but the clock is running. The reasons to move are practical: async error handling without wrappers, a stricter path matcher that rejects ambiguous patterns at startup, and new middleware and tutorials targeting 5.</p>
<p>Check your runtime first. Express 5 requires Node 18+, and <a href="https://github.com/nodejs/release#release-schedule" target="_blank" rel="noopener noreferrer">Node 20 reaches end of life on April 30, 2026</a>. If you are moving anyway, Node 24 LTS is the sensible target.</p>
<h2 id="step-by-step-express-5-migration-plan">Step-by-step Express 5 migration plan <a class="post-heading-anchor" href="#step-by-step-express-5-migration-plan" aria-hidden="true">#</a></h2>
<h3 id="1-upgrade-and-run-the-codemods">1. Upgrade and run the codemods <a class="post-heading-anchor" href="#1-upgrade-and-run-the-codemods" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">bash</span></div><pre><code class="language-bash hljs">npm install express@5
npm install --save-dev @types/express@5   <span class="hljs-comment"># TypeScript only</span>
npx @expressjs/codemod upgrade
</code></pre></div>
<p>The <a href="https://www.npmjs.com/package/@expressjs/codemod" target="_blank" rel="noopener noreferrer">official codemod</a> rewrites the mechanical changes: <code>res.redirect('back')</code>, pluralized <code>req.accepts*</code> methods, the old <code>res.json(obj, status)</code> style signatures and <code>req.param()</code>. It does not fix route paths.</p>
<h3 id="2-find-the-route-patterns-that-will-throw">2. Find the route patterns that will throw <a class="post-heading-anchor" href="#2-find-the-route-patterns-that-will-throw" aria-hidden="true">#</a></h3>
<p>Search for patterns <a href="https://github.com/pillarjs/path-to-regexp" target="_blank" rel="noopener noreferrer">path-to-regexp</a> v8 rejects:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">bash</span></div><pre><code class="language-bash hljs">grep -rnE <span class="hljs-string">"\.(get|post|put|patch|delete|all|use)\(\s*['\"][^'\"]*(\*|\?|\(|\))"</span> src/
</code></pre></div>
<p>Express 5 throws at registration time, so the app will not even start until they are fixed. That is the good news: nothing slips through to production. Here is what failed when registering common Express 4 patterns on 5.2:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">text</span></div><pre><code class="language-text hljs">THROW /*               -&gt; Missing parameter name at index 2: /*
THROW /users/:id?      -&gt; Unexpected ? at index 10: /users/:id?
THROW /files/:path(*)  -&gt; Unexpected ( at index 12: /files/:path(*)
THROW /api/v(1|2)/x    -&gt; Unexpected ( at index 6: /api/v(1|2)/x
</code></pre></div>
<h3 id="3-rewrite-them">3. Rewrite them <a class="post-heading-anchor" href="#3-rewrite-them" aria-hidden="true">#</a></h3>
<div class="post-table"><table>
<thead>
<tr>
<th>Express 4</th>
<th>Express 5</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>app.get('/*', ...)</code></td>
<td><code>app.get('/*splat', ...)</code>, or <code>'/{*splat}'</code> to also match <code>/</code></td>
</tr>
<tr>
<td><code>'/users/:id?'</code></td>
<td><code>'/users{/:id}'</code></td>
</tr>
<tr>
<td><code>'/files/:path(*)'</code></td>
<td><code>'/files/*path'</code></td>
</tr>
<tr>
<td><code>'/api/v(1|2)/x'</code></td>
<td><code>['/api/v1/x', '/api/v2/x']</code></td>
</tr>
</tbody>
</table></div>
<p>Wildcard params are now arrays: <code>/*splat</code> on <code>/a/b/c</code> gives <code>req.params.splat</code> as <code>['a', 'b', 'c']</code>. Code that did <code>req.params[0]</code> needs <code>req.params.splat.join('/')</code>. Optional parameters that are missing are omitted from <code>req.params</code>, not set to <code>undefined</code>.</p>
<h2 id="what-breaks-at-runtime-after-the-express-5-upgrade">What breaks at runtime after the Express 5 upgrade <a class="post-heading-anchor" href="#what-breaks-at-runtime-after-the-express-5-upgrade" aria-hidden="true">#</a></h2>
<p>These do not crash at startup, which makes them the dangerous ones.</p>
<h3 id="req-query-is-now-a-getter">req.query is now a getter <a class="post-heading-anchor" href="#req-query-is-now-a-getter" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// Express 4 pattern that silently stops working</span>
app.<span class="hljs-title">use</span>(<span class="hljs-function">(<span class="hljs-params">req, res, next</span>) =&gt;</span> {
  req.<span class="hljs-property">query</span>.<span class="hljs-property">limit</span> ??= <span class="hljs-string">'50'</span>; <span class="hljs-comment">// lost: req.query is re-parsed on every access</span>
  <span class="hljs-title">next</span>();
});
</code></pre></div>
<p>In ESM (strict mode), <code>req.query = {...}</code> throws <code>Cannot set property query of #&lt;IncomingMessage&gt; which has only a getter</code>. Mutating a property does not throw; the change just disappears. Put normalized values somewhere you own:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">app.<span class="hljs-title">use</span>(<span class="hljs-function">(<span class="hljs-params">req, res, next</span>) =&gt;</span> {
  res.<span class="hljs-property">locals</span>.<span class="hljs-property">page</span> = {
    <span class="hljs-attr">limit</span>: <span class="hljs-title">Math</span>.<span class="hljs-title">min</span>(<span class="hljs-title">Number</span>(req.<span class="hljs-property">query</span>.<span class="hljs-property">limit</span> ?? <span class="hljs-number">50</span>), <span class="hljs-number">200</span>),
    <span class="hljs-attr">cursor</span>: req.<span class="hljs-property">query</span>.<span class="hljs-property">cursor</span> ?? <span class="hljs-literal">null</span>,
  };
  <span class="hljs-title">next</span>();
});
</code></pre></div>
<h3 id="the-default-query-parser-is-simple">The default query parser is “simple” <a class="post-heading-anchor" href="#the-default-query-parser-is-simple" aria-hidden="true">#</a></h3>
<p><code>?filter[status]=open</code> used to give <code>{ filter: { status: 'open' } }</code>. In Express 5 it gives <code>{ 'filter[status]': 'open' }</code>. If your API uses bracket syntax, opt back in with <code>app.set('query parser', 'extended')</code>. Same story for forms: <code>express.urlencoded()</code> now defaults to <code>extended: false</code>.</p>
<h3 id="req-body-is-undefined-not">req.body is undefined, not {} <a class="post-heading-anchor" href="#req-body-is-undefined-not" aria-hidden="true">#</a></h3>
<p>When no body parser ran (no body, wrong <code>Content-Type</code>), <code>req.body</code> is <code>undefined</code>. Code like <code>const { email } = req.body</code> now throws a <code>TypeError</code>, which becomes a 500. <a href="/blog/zod-validation-express-fastify">Validate the body</a>, or default it: <code>const { email } = req.body ?? {}</code>.</p>
<h3 id="res-status-validates-its-argument">res.status() validates its argument <a class="post-heading-anchor" href="#res-status-validates-its-argument" aria-hidden="true">#</a></h3>
<p><code>res.status('201')</code> throws <code>Invalid status code: "201". Status code must be an integer.</code> Values must be integers from 100 to 999. Watch for status codes read from config, upstream responses or error objects as strings.</p>
<h3 id="rejected-promises-go-to-your-error-handler">Rejected promises go to your error handler <a class="post-heading-anchor" href="#rejected-promises-go-to-your-error-handler" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">app.<span class="hljs-title">get</span>(<span class="hljs-string">'/v1/orders/:id'</span>, <span class="hljs-title">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> order = <span class="hljs-keyword">await</span> db.<span class="hljs-property">orders</span>.<span class="hljs-title">find</span>(req.<span class="hljs-property">params</span>.<span class="hljs-property">id</span>); <span class="hljs-comment">// a throw here...</span>
  res.<span class="hljs-title">json</span>(order);
});

app.<span class="hljs-title">use</span>(<span class="hljs-function">(<span class="hljs-params">err, req, res, next</span>) =&gt;</span> {                  <span class="hljs-comment">// ...lands here</span>
  req.<span class="hljs-property">log</span>?.<span class="hljs-title">error</span>({ err }, <span class="hljs-string">'unhandled route error'</span>);
  res.<span class="hljs-title">status</span>(err.<span class="hljs-property">status</span> ?? <span class="hljs-number">500</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'internal_error'</span> });
});
</code></pre></div>
<p>You can delete <code>express-async-errors</code> and hand-written <code>asyncHandler</code> wrappers. Check that your error handler sets a sensible status, because errors that used to become unhandled rejections now produce responses.</p>
<h3 id="smaller-changes-worth-a-search">Smaller changes worth a search <a class="post-heading-anchor" href="#smaller-changes-worth-a-search" aria-hidden="true">#</a></h3>
<ul>
<li><code>app.del()</code> is gone; use <code>app.delete()</code>.</li>
<li><code>res.sendfile()</code> is now <code>res.sendFile()</code>.</li>
<li><code>express.static</code> ignores dotfiles by default; serve <code>/.well-known</code> explicitly with <code>dotfiles: 'allow'</code>.</li>
<li><code>req.host</code> keeps the port.</li>
<li><code>app.listen</code> passes errors such as <code>EADDRINUSE</code> to its callback instead of throwing.</li>
</ul>
<h2 id="express-5-migration-checklist">Express 5 migration checklist <a class="post-heading-anchor" href="#express-5-migration-checklist" aria-hidden="true">#</a></h2>
<p>Forward this to whoever owns the upgrade:</p>
<ul>
<li>[ ] Node 18+ everywhere (ideally Node 22 or 24).</li>
<li>[ ] <code>npx @expressjs/codemod upgrade</code> run and reviewed.</li>
<li>[ ] App starts: every route pattern registers.</li>
<li>[ ] No writes to <code>req.query</code>; bracket queries still parse.</li>
<li>[ ] No unguarded destructuring of <code>req.body</code>.</li>
<li>[ ] Every <code>res.status()</code> receives an integer.</li>
<li>[ ] Error handler returns a sane status for async errors.</li>
<li>[ ] Deployed to staging with real traffic before production.</li>
</ul>
<p>A solo developer can do this in one sitting. A team of ten should split it: one person on route patterns, one on query and body handling, and a staging soak with production-like traffic before the cutover, ideally after you have <a href="/blog/nodejs-graceful-shutdown">graceful shutdown</a> in place so the deploy itself does not drop requests.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="is-express-5-backwards-compatible-with-express-4">Is Express 5 backwards compatible with Express 4? <a class="post-heading-anchor" href="#is-express-5-backwards-compatible-with-express-4" aria-hidden="true">#</a></h3>
<p>Mostly, but not fully. Most middleware works unchanged, while route path syntax, <code>req.query</code>, <code>req.body</code> defaults and several removed methods need changes. Expect your app to fail at startup until route patterns are updated.</p>
<h3 id="how-do-i-fix-missing-parameter-name-in-express-5">How do I fix “Missing parameter name” in Express 5? <a class="post-heading-anchor" href="#how-do-i-fix-missing-parameter-name-in-express-5" aria-hidden="true">#</a></h3>
<p>Wildcards must be named. Replace <code>/*</code> with <code>/*splat</code> (or <code>/{*splat}</code> to also match the root) and read the matched segments from <code>req.params.splat</code>, which is an array.</p>
<h3 id="do-i-still-need-express-async-errors-in-express-5">Do I still need express-async-errors in Express 5? <a class="post-heading-anchor" href="#do-i-still-need-express-async-errors-in-express-5" aria-hidden="true">#</a></h3>
<p>No. Express 5 forwards rejected promises and errors thrown in async handlers to your error middleware, as if you had called <code>next(err)</code>.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>The scary part of the Express 5 migration is the runtime changes that do not crash. If you would rather not eyeball logs to find them, ApiTraffic captures every request and response from <a href="https://www.apitraffic.io/docs/integrations/express">one middleware placed after your body parsers</a>, and records them under the <a href="https://www.apitraffic.io/docs/endpoint-handling">matched route pattern</a>, so a route that stopped matching shows up as traffic moving to 404.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">app.<span class="hljs-title">use</span>(express.<span class="hljs-title">json</span>());
app.<span class="hljs-title">use</span>(apitraffic.<span class="hljs-title">express</span>());
</code></pre></div>
<p>After the deploy, search for <code>status_code &gt;= 500</code> per endpoint, or <a href="https://www.apitraffic.io/docs/events">record a deploy event</a> and use <a href="https://www.apitraffic.io/docs/apm/analytics">Regressions</a> to compare each endpoint’s p95 before and after the upgrade.</p>
<p>Start free and watch your Express 5 upgrade request by request: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260316-express-5-migration">apitraffic.io</a></p>
<p>Sources: <a href="https://expressjs.com/en/guide/migrating-5.html" target="_blank" rel="noopener noreferrer">Express: Moving to Express 5</a>; <a href="https://expressjs.com/en/blog/2025-03-31-v5-1-latest-release" target="_blank" rel="noopener noreferrer">Express 5.1.0 release announcement (March 2025)</a>; <a href="https://www.npmjs.com/package/@expressjs/codemod" target="_blank" rel="noopener noreferrer">@expressjs/codemod</a>; <a href="https://github.com/pillarjs/path-to-regexp" target="_blank" rel="noopener noreferrer">path-to-regexp README</a>; <a href="https://github.com/nodejs/release#release-schedule" target="_blank" rel="noopener noreferrer">Node.js release schedule</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>SLOs and an error budget for a small API team, without the enterprise ceremony</title>
      <link>https://www.apitraffic.io/blog/error-budget-small-api-team</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/error-budget-small-api-team</guid>
      <pubDate>Mon, 09 Mar 2026 13:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>Is a 0.4% error rate bad? Without a target, nobody knows. Set one availability and one latency SLO for your Node API, compute the error budget from your request logs, and agree what happens when it runs out.</description>
      <content:encoded><![CDATA[<p>A customer emails to say checkout “keeps failing”, you look at the dashboard, and the error rate is 0.4%. Is that bad? Without an agreed target, nobody knows, so the argument is about feelings. An SLO and its error budget turn that into arithmetic.</p>
<p>The short version: pick two service level indicators for your API (the share of requests that do not fail with a 5xx, and the share that finish under a latency threshold), set a target over a rolling 28 days, say 99.5%, and treat the remaining 0.5% as an error budget you are allowed to spend. When the budget is healthy, ship. When it is nearly gone, the next week’s work is reliability. Everything below is how to do that in an afternoon with the request logs you already have.</p>
<h2 id="what-is-an-error-budget-in-one-paragraph">What is an error budget, in one paragraph? <a class="post-heading-anchor" href="#what-is-an-error-budget-in-one-paragraph" aria-hidden="true">#</a></h2>
<p>A service level objective (SLO) is a target for a measurable behavior, such as “99.5% of API requests succeed over 28 days”. The error budget is the 0.5% you did not promise. If you serve 2,000,000 requests in 28 days, the budget is 10,000 failed requests. Measured in time, 0.5% of 28 days is about 3 hours 22 minutes of total outage. The budget is not a goal to hit; it is permission to take risks (deploys, migrations, experiments) until it runs low.</p>
<div class="post-table"><table>
<thead>
<tr>
<th>Target over 28 days</th>
<th>Error budget</th>
<th>Equivalent full downtime</th>
</tr>
</thead>
<tbody>
<tr>
<td>99%</td>
<td>1% of requests</td>
<td>about 6 h 43 min</td>
</tr>
<tr>
<td>99.5%</td>
<td>0.5%</td>
<td>about 3 h 22 min</td>
</tr>
<tr>
<td>99.9%</td>
<td>0.1%</td>
<td>about 40 min</td>
</tr>
<tr>
<td>99.95%</td>
<td>0.05%</td>
<td>about 20 min</td>
</tr>
</tbody>
</table></div>
<p>For a team of one to ten running on a single cloud region and a managed database, 99.9% is ambitious; your hosting provider’s own incidents can use that up. Start at 99.5% and tighten it once you have a quarter of data.</p>
<h2 id="how-to-choose-slis-for-a-node-api">How to choose SLIs for a Node API <a class="post-heading-anchor" href="#how-to-choose-slis-for-a-node-api" aria-hidden="true">#</a></h2>
<p>An SLI (service level indicator) is a ratio: good events divided by valid events. Two are enough for most APIs.</p>
<h3 id="availability-sli">Availability SLI <a class="post-heading-anchor" href="#availability-sli" aria-hidden="true">#</a></h3>
<p>Good: responses with a status below 500. Valid: all requests, minus the ones you exclude. Exclude:</p>
<ul>
<li>health checks and readiness probes (they would inflate the denominator; see our post on <a href="/blog/nodejs-health-check">liveness vs readiness checks</a>)</li>
<li>requests your firewall or rate limiter rejected on purpose</li>
<li>most 4xx: a <code>404</code> for a bad URL or a <code>401</code> for a wrong password is the client’s problem</li>
</ul>
<p>Count <code>429</code> from your own limiter as excluded, but count <code>408</code>, <code>502</code>, <code>503</code> and <code>504</code> as bad. A <code>499</code> (client closed the connection) is a judgment call; if it happens because your API is slow, the latency SLI will catch it.</p>
<h3 id="latency-sli">Latency SLI <a class="post-heading-anchor" href="#latency-sli" aria-hidden="true">#</a></h3>
<p>Good: requests that finished under a threshold. Valid: the same set as above. Pick the threshold per group of endpoints, not one number for everything. A <code>GET /v1/orders/:id</code> might have a 300 ms threshold, and a <code>POST /v1/reports</code> that builds a PDF might have 3 seconds.</p>
<p>Phrase it as a proportion (“95% of requests under 300 ms”), not as “p95 under 300 ms”. The two sound alike, but the proportion is additive across time windows and servers, and percentiles are not. Our post on <a href="/blog/api-latency-percentiles">reading p50, p95 and p99 without fooling yourself</a> explains why averaging percentiles goes wrong.</p>
<h2 id="calculate-your-slo-and-error-budget-from-request-logs">Calculate your SLO and error budget from request logs <a class="post-heading-anchor" href="#calculate-your-slo-and-error-budget-from-request-logs" aria-hidden="true">#</a></h2>
<p>If your app writes one JSON line per request with a route pattern, status and duration, you already have everything. This script reads NDJSON logs and reports both SLIs and the budget left:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// slo-report.mjs  -- usage: node slo-report.mjs requests.ndjson</span>
<span class="hljs-keyword">import</span> { createReadStream } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:fs'</span>;
<span class="hljs-keyword">import</span> { createInterface } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:readline'</span>;

<span class="hljs-keyword">const</span> <span class="hljs-variable">TARGET</span> = <span class="hljs-number">0.995</span>;                       <span class="hljs-comment">// 99.5% over the window</span>
<span class="hljs-keyword">const</span> <span class="hljs-variable">LATENCY_MS</span> = { <span class="hljs-attr">default</span>: <span class="hljs-number">300</span>, <span class="hljs-string">'POST /v1/reports'</span>: <span class="hljs-number">3000</span> };
<span class="hljs-keyword">const</span> <span class="hljs-variable">EXCLUDE</span> = [<span class="hljs-regexp">/^GET \/healthz/</span>, <span class="hljs-regexp">/^GET \/readyz/</span>];

<span class="hljs-keyword">let</span> valid = <span class="hljs-number">0</span>, failed = <span class="hljs-number">0</span>, slow = <span class="hljs-number">0</span>;

<span class="hljs-keyword">const</span> rl = <span class="hljs-title">createInterface</span>({ <span class="hljs-attr">input</span>: <span class="hljs-title">createReadStream</span>(process.<span class="hljs-property">argv</span>[<span class="hljs-number">2</span>]) });
<span class="hljs-keyword">for</span> <span class="hljs-title">await</span> (<span class="hljs-keyword">const</span> line <span class="hljs-keyword">of</span> rl) {
  <span class="hljs-keyword">if</span> (!line.<span class="hljs-title">trim</span>()) <span class="hljs-keyword">continue</span>;
  <span class="hljs-keyword">const</span> r = <span class="hljs-title">JSON</span>.<span class="hljs-title">parse</span>(line);              <span class="hljs-comment">// { method, route, status, durationMs }</span>
  <span class="hljs-keyword">const</span> key = <span class="hljs-string">`<span class="hljs-subst">${r.method}</span> <span class="hljs-subst">${r.route}</span>`</span>;
  <span class="hljs-keyword">if</span> (<span class="hljs-variable">EXCLUDE</span>.<span class="hljs-title">some</span>(<span class="hljs-function">(<span class="hljs-params">re</span>) =&gt;</span> re.<span class="hljs-title">test</span>(key)) || r.<span class="hljs-property">status</span> === <span class="hljs-number">429</span>) <span class="hljs-keyword">continue</span>;
  valid++;
  <span class="hljs-keyword">if</span> (r.<span class="hljs-property">status</span> &gt;= <span class="hljs-number">500</span>) failed++;
  <span class="hljs-keyword">if</span> (r.<span class="hljs-property">durationMs</span> &gt; (<span class="hljs-variable">LATENCY_MS</span>[key] ?? <span class="hljs-variable">LATENCY_MS</span>.<span class="hljs-property">default</span>)) slow++;
}

<span class="hljs-keyword">const</span> budget = valid * (<span class="hljs-number">1</span> - <span class="hljs-variable">TARGET</span>);
<span class="hljs-keyword">const</span> <span class="hljs-title">report</span> = (<span class="hljs-params">name, bad</span>) =&gt; {
  <span class="hljs-keyword">const</span> sli = <span class="hljs-number">1</span> - bad / valid;
  <span class="hljs-keyword">const</span> left = <span class="hljs-number">1</span> - bad / budget;
  <span class="hljs-variable">console</span>.<span class="hljs-title">log</span>(<span class="hljs-string">`<span class="hljs-subst">${name}</span>: SLI <span class="hljs-subst">${(sli * <span class="hljs-number">100</span>).toFixed(<span class="hljs-number">3</span>)}</span>%, budget left <span class="hljs-subst">${(left * <span class="hljs-number">100</span>).toFixed(<span class="hljs-number">1</span>)}</span>%`</span>);
};
<span class="hljs-variable">console</span>.<span class="hljs-title">log</span>(<span class="hljs-string">`valid requests: <span class="hljs-subst">${valid}</span>`</span>);
<span class="hljs-title">report</span>(<span class="hljs-string">'availability'</span>, failed);
<span class="hljs-title">report</span>(<span class="hljs-string">'latency'</span>, slow);
</code></pre></div>
<p>Run it over the last 28 days of logs once a week. The output is two lines, which is the right amount of ceremony for a small team.</p>
<p>Use the route pattern (<code>/v1/orders/:id</code>), not the raw URL, or every order ID becomes its own row.</p>
<h2 id="burn-rate-when-to-worry-before-the-budget-is-gone">Burn rate: when to worry before the budget is gone <a class="post-heading-anchor" href="#burn-rate-when-to-worry-before-the-budget-is-gone" aria-hidden="true">#</a></h2>
<p>Burn rate is how fast you are spending the budget relative to plan. A burn rate of 1 spends exactly the whole budget over the window; a burn rate of 10 spends it in a tenth of the time. <a href="https://sre.google/workbook/alerting-on-slos/" target="_blank" rel="noopener noreferrer">Google’s SRE workbook suggests alerting</a> on combinations like these for a 30-day window:</p>
<div class="post-table"><table>
<thead>
<tr>
<th>Budget spent</th>
<th>Long window</th>
<th>Short window</th>
<th>Burn rate</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr>
<td>2%</td>
<td>1 hour</td>
<td>5 minutes</td>
<td>14.4</td>
<td>Page</td>
</tr>
<tr>
<td>5%</td>
<td>6 hours</td>
<td>30 minutes</td>
<td>6</td>
<td>Page</td>
</tr>
<tr>
<td>10%</td>
<td>3 days</td>
<td>6 hours</td>
<td>1</td>
<td>Ticket</td>
</tr>
</tbody>
</table></div>
<p>The short window makes the alert stop soon after the problem does. For a team without a formal on-call rotation, the first row alone is a reasonable start: it fires when something is badly broken right now, and stays quiet for a single slow request at 3am.</p>
<h2 id="write-an-error-budget-policy-in-five-lines">Write an error budget policy in five lines <a class="post-heading-anchor" href="#write-an-error-budget-policy-in-five-lines" aria-hidden="true">#</a></h2>
<p>The SLO only changes behavior if you agree in advance what happens when the budget runs low. Keep it short enough to fit in the README:</p>
<ol>
<li>Budget above 50%: ship normally.</li>
<li>Budget between 0% and 50%: risky changes (schema migrations, dependency majors) need a second reviewer and a rollback plan.</li>
<li>Budget exhausted: the next week of planned work is reliability fixes from the incidents that spent it.</li>
<li>One incident that spends more than 20% of the budget gets a short written review.</li>
<li>Review targets once a quarter.</li>
</ol>
<p>For a one-person team, this is a note to yourself that makes “should I ship the Postgres upgrade on Friday” an easy no. For a ten-person team, it is the thing that lets the lead say “this week is reliability” without a debate.</p>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<ul>
<li>Pick one availability SLI: non-5xx share of valid requests, excluding health checks.</li>
<li>Set a 99.5% target over 28 days.</li>
<li>Run a weekly report and post the budget left in your team chat.</li>
<li>Agree on rule 3 above before you need it.</li>
</ul>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="what-is-a-good-slo-for-a-rest-api">What is a good SLO for a REST API? <a class="post-heading-anchor" href="#what-is-a-good-slo-for-a-rest-api" aria-hidden="true">#</a></h3>
<p>For a small team on managed infrastructure, 99.5% availability over 28 days is a sensible first target, with a latency SLO like 95% of reads under 300 ms. Set it from your last month’s data, not from what sounds impressive.</p>
<h3 id="should-4xx-errors-count-against-the-error-budget">Should 4xx errors count against the error budget? <a class="post-heading-anchor" href="#should-4xx-errors-count-against-the-error-budget" aria-hidden="true">#</a></h3>
<p>Usually not. A 4xx means the client sent something wrong. The exceptions are errors your code produces by mistake, like a <code>400</code> from a validation bug after a deploy; watch for sudden 4xx jumps separately.</p>
<h3 id="what-is-the-difference-between-an-slo-and-an-sla">What is the difference between an SLO and an SLA? <a class="post-heading-anchor" href="#what-is-the-difference-between-an-slo-and-an-sla" aria-hidden="true">#</a></h3>
<p>An SLA is a contract with consequences, such as credits. An SLO is your internal target, and it should be stricter than any SLA so you have room to react before you owe anyone money.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not maintain the logging and the script, ApiTraffic records every request under its matched route pattern, with status and latency, from one middleware. <a href="https://www.apitraffic.io/docs/endpoint-handling">Endpoint details</a> show requests and p50, p95 and p99 per endpoint over time, which is the raw material for both SLIs.</p>
<p>When the budget drops, the <a href="https://www.apitraffic.io/docs/traffic/request-log">request log</a> answers “which requests spent it”: <code>status_code &gt;= 500</code> narrowed by <code>url_path</code>, or <code>tag:userId == "1042" AND status_code &gt;= 400</code> for one customer’s complaint (see the <a href="https://www.apitraffic.io/docs/traffic/search">request search language</a>). Regressions compares p95 before and after a <a href="https://www.apitraffic.io/docs/events">deploy event</a>, which is usually the next question.</p>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260309-error-budget-small-api-team">apitraffic.io</a></p>
<p>Sources: <a href="https://sre.google/workbook/alerting-on-slos/" target="_blank" rel="noopener noreferrer">Google SRE Workbook, “Alerting on SLOs”</a> ; <a href="https://sre.google/workbook/implementing-slos/" target="_blank" rel="noopener noreferrer">Google SRE Workbook, “Implementing SLOs”</a> ; <a href="https://sre.google/sre-book/service-level-objectives/" target="_blank" rel="noopener noreferrer">Google SRE Book, “Service Level Objectives”</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Credential stuffing detection for Node APIs: spotting it in your login logs</title>
      <link>https://www.apitraffic.io/blog/credential-stuffing-detection</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/credential-stuffing-detection</guid>
      <pubDate>Thu, 26 Feb 2026 14:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>Every request looks like a normal login, which is why stuffing goes unseen until a customer reports a login they did not make. Here is how to see it in your logs.</description>
      <content:encoded><![CDATA[<p>The first sign is usually a support ticket: a customer asks why their account shows a login they did not make. You check the login route and see nothing alarming, because every request was a well-formed login with a real email and a password that worked somewhere else.</p>
<p>Credential stuffing detection comes down to looking at login attempts in aggregate instead of one at a time. The strongest signals are a rising failure ratio, a spike in failures for accounts that do not exist, and single sources trying many different accounts. This post shows what to log on your login route to see those signals, plus the code and queries that turn them into an alert.</p>
<h2 id="what-is-credential-stuffing-and-why-is-it-hard-to-detect">What is credential stuffing, and why is it hard to detect? <a class="post-heading-anchor" href="#what-is-credential-stuffing-and-why-is-it-hard-to-detect" aria-hidden="true">#</a></h2>
<p>Brute force hammers one account with many passwords. Credential stuffing tries leaked email and password pairs from other breaches, usually one or two attempts per account, spread across many IPs. Per-account lockouts barely trigger, and per-IP limits only catch the clumsy operators.</p>
<p>It is common. <a href="https://www.verizon.com/business/resources/articles/credential-stuffing-attacks-2025-dbir-research/" target="_blank" rel="noopener noreferrer">Verizon’s 2025 DBIR research</a> found that in the median case credential stuffing made up 19% of SSO authentication attempts in its data, 12% for small businesses, with a single-day peak of 44%. The same research found that in the median case only 49% of a user’s passwords across services were distinct. You do not need to be a big target. The attacker’s list picks you.</p>
<p>OWASP files this as automated threat <a href="https://owasp.org/www-project-automated-threats-to-web-applications/" target="_blank" rel="noopener noreferrer">OAT-008</a>, and its <a href="https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html" target="_blank" rel="noopener noreferrer">Credential Stuffing Prevention Cheat Sheet</a> is the best reference for defenses. This post covers detection, because you cannot tune a defense you cannot see.</p>
<h2 id="credential-stuffing-signals-in-request-logs">Credential stuffing signals in request logs <a class="post-heading-anchor" href="#credential-stuffing-signals-in-request-logs" aria-hidden="true">#</a></h2>
<div class="post-table"><table>
<thead>
<tr>
<th>Signal</th>
<th>Normal traffic</th>
<th>Stuffing run</th>
</tr>
</thead>
<tbody>
<tr>
<td>401 to 200 ratio on login</td>
<td>Low, stable</td>
<td>Many times your baseline</td>
</tr>
<tr>
<td>Failures for unknown accounts</td>
<td>Rare typos</td>
<td>Large share of failures</td>
</tr>
<tr>
<td>Distinct accounts per IP or ASN</td>
<td>One or two</td>
<td>Dozens or hundreds</td>
</tr>
<tr>
<td>Attempts per account</td>
<td>Several on a bad day</td>
<td>One or two</td>
</tr>
<tr>
<td>Client headers</td>
<td>Your frontend’s headers</td>
<td>Missing or identical everywhere</td>
</tr>
<tr>
<td>Successes</td>
<td>Spread across users</td>
<td>A few from the same noisy sources</td>
</tr>
</tbody>
</table></div>
<p>The last row is the one that matters most. A handful of 200s from the IP ranges that produced thousands of 401s are compromised accounts.</p>
<h2 id="how-to-log-login-attempts-for-credential-stuffing-detection">How to log login attempts for credential stuffing detection <a class="post-heading-anchor" href="#how-to-log-login-attempts-for-credential-stuffing-detection" aria-hidden="true">#</a></h2>
<p>Most of these signals need fields a default access log does not have. This <a href="https://www.apitraffic.io/express">Express</a> 5 handler records an outcome you can aggregate, without logging the password or the raw email.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> { createHmac } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:crypto'</span>;
<span class="hljs-keyword">import</span> pino <span class="hljs-keyword">from</span> <span class="hljs-string">'pino'</span>;

<span class="hljs-keyword">const</span> log = <span class="hljs-title">pino</span>();
<span class="hljs-keyword">const</span> app = <span class="hljs-title">express</span>();
app.<span class="hljs-title">use</span>(express.<span class="hljs-title">json</span>({ <span class="hljs-attr">limit</span>: <span class="hljs-string">'10kb'</span> }));
app.<span class="hljs-title">set</span>(<span class="hljs-string">'trust proxy'</span>, <span class="hljs-number">1</span>); <span class="hljs-comment">// only behind exactly one proxy you control</span>

<span class="hljs-keyword">const</span> <span class="hljs-variable">PEPPER</span> = process.<span class="hljs-property">env</span>.<span class="hljs-property">LOGIN_LOG_PEPPER</span>;
<span class="hljs-keyword">const</span> <span class="hljs-title">accountKey</span> = (<span class="hljs-params">email</span>) =&gt;
  <span class="hljs-title">createHmac</span>(<span class="hljs-string">'sha256'</span>, <span class="hljs-variable">PEPPER</span>)
    .<span class="hljs-title">update</span>(<span class="hljs-title">String</span>(email).<span class="hljs-title">trim</span>().<span class="hljs-title">toLowerCase</span>())
    .<span class="hljs-title">digest</span>(<span class="hljs-string">'hex'</span>)
    .<span class="hljs-title">slice</span>(<span class="hljs-number">0</span>, <span class="hljs-number">16</span>);

app.<span class="hljs-title">post</span>(<span class="hljs-string">'/v1/auth/login'</span>, <span class="hljs-title">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> { email, password } = req.<span class="hljs-property">body</span> ?? {};
  <span class="hljs-keyword">const</span> user = <span class="hljs-keyword">await</span> <span class="hljs-title">findUserByEmail</span>(email);
  <span class="hljs-keyword">const</span> ok = user ? <span class="hljs-keyword">await</span> <span class="hljs-title">verifyPassword</span>(user, password) : <span class="hljs-literal">false</span>;
  <span class="hljs-keyword">const</span> outcome = !user ? <span class="hljs-string">'unknown_user'</span> : ok ? <span class="hljs-string">'success'</span> : <span class="hljs-string">'bad_password'</span>;

  log.<span class="hljs-title">info</span>({
    <span class="hljs-attr">event</span>: <span class="hljs-string">'login_attempt'</span>,
    outcome,
    <span class="hljs-attr">account</span>: <span class="hljs-title">accountKey</span>(email ?? <span class="hljs-string">''</span>),
    <span class="hljs-attr">ip</span>: req.<span class="hljs-property">ip</span>,
    <span class="hljs-attr">ua</span>: req.<span class="hljs-title">get</span>(<span class="hljs-string">'user-agent'</span>) ?? <span class="hljs-string">'none'</span>,
    <span class="hljs-attr">hasClientHeader</span>: <span class="hljs-title">Boolean</span>(req.<span class="hljs-title">get</span>(<span class="hljs-string">'x-client-version'</span>)),
  });

  <span class="hljs-keyword">if</span> (!ok) <span class="hljs-keyword">return</span> res.<span class="hljs-title">status</span>(<span class="hljs-number">401</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'invalid_credentials'</span> });
  res.<span class="hljs-title">json</span>({ <span class="hljs-attr">token</span>: <span class="hljs-keyword">await</span> <span class="hljs-title">issueToken</span>(user) });
});
</code></pre></div>
<p>The response is identical for an unknown user and a wrong password, so attackers cannot use your API to validate their list. Internally the log tells them apart, because “unknown user” is your strongest signal. Make sure <code>verifyPassword</code> still does a hash comparison when the user does not exist, or timing gives the answer away.</p>
<p>The HMAC with a pepper lets you count distinct accounts per IP without putting emails in logs. More on keeping personal data out of logs in <a href="/blog/redact-sensitive-data-logs">what not to log</a>.</p>
<h2 id="detecting-credential-stuffing-in-code">Detecting credential stuffing in code <a class="post-heading-anchor" href="#detecting-credential-stuffing-in-code" aria-hidden="true">#</a></h2>
<p>A small in-process detector flags a source that tries too many distinct accounts. This is a single-instance sketch. With several instances, keep the same structure in Redis.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">const</span> <span class="hljs-variable">WINDOW_MS</span> = <span class="hljs-number">10</span> * <span class="hljs-number">60</span> * <span class="hljs-number">1000</span>;
<span class="hljs-keyword">const</span> <span class="hljs-variable">MAX_DISTINCT_ACCOUNTS</span> = <span class="hljs-number">20</span>;
<span class="hljs-keyword">const</span> sources = <span class="hljs-keyword">new</span> <span class="hljs-title">Map</span>(); <span class="hljs-comment">// ip -&gt; { accounts, failures, first }</span>

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">recordAttempt</span>(<span class="hljs-params">{ ip, account, outcome }</span>) {
  <span class="hljs-keyword">const</span> now = <span class="hljs-title">Date</span>.<span class="hljs-title">now</span>();
  <span class="hljs-keyword">let</span> s = sources.<span class="hljs-title">get</span>(ip);
  <span class="hljs-keyword">if</span> (!s || now - s.<span class="hljs-property">first</span> &gt; <span class="hljs-variable">WINDOW_MS</span>) {
    s = { <span class="hljs-attr">accounts</span>: <span class="hljs-keyword">new</span> <span class="hljs-title">Set</span>(), <span class="hljs-attr">failures</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">first</span>: now };
    sources.<span class="hljs-title">set</span>(ip, s);
  }
  s.<span class="hljs-property">accounts</span>.<span class="hljs-title">add</span>(account);
  <span class="hljs-keyword">if</span> (outcome !== <span class="hljs-string">'success'</span>) s.<span class="hljs-property">failures</span>++;
  <span class="hljs-keyword">const</span> suspicious =
    s.<span class="hljs-property">accounts</span>.<span class="hljs-property">size</span> &gt; <span class="hljs-variable">MAX_DISTINCT_ACCOUNTS</span> &amp;&amp; s.<span class="hljs-property">failures</span> / s.<span class="hljs-property">accounts</span>.<span class="hljs-property">size</span> &gt; <span class="hljs-number">0.8</span>;
  <span class="hljs-keyword">return</span> { suspicious, <span class="hljs-attr">distinct</span>: s.<span class="hljs-property">accounts</span>.<span class="hljs-property">size</span> };
}

<span class="hljs-built_in">setInterval</span>(<span class="hljs-function">() =&gt;</span> {
  <span class="hljs-keyword">const</span> cutoff = <span class="hljs-title">Date</span>.<span class="hljs-title">now</span>() - <span class="hljs-variable">WINDOW_MS</span>;
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [ip, s] <span class="hljs-keyword">of</span> sources) <span class="hljs-keyword">if</span> (s.<span class="hljs-property">first</span> &lt; cutoff) sources.<span class="hljs-title">delete</span>(ip);
}, <span class="hljs-number">60_000</span>).<span class="hljs-title">unref</span>();
</code></pre></div>
<p>When <code>suspicious</code> is true, log and alert before you block. Stuffing often comes through residential proxies, where one IP can also carry real users.</p>
<h2 id="sql-queries-to-find-credential-stuffing">SQL queries to find credential stuffing <a class="post-heading-anchor" href="#sql-queries-to-find-credential-stuffing" aria-hidden="true">#</a></h2>
<p>If your logs land in a queryable <code>login_attempts</code> table, ask two questions. Failure ratio by hour:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">sql</span></div><pre><code class="language-sql hljs"><span class="hljs-keyword">SELECT</span> date_trunc(<span class="hljs-string">'hour'</span>, ts) <span class="hljs-keyword">AS</span> <span class="hljs-keyword">hour</span>,
       <span class="hljs-built_in">count</span>(<span class="hljs-operator">*</span>) <span class="hljs-keyword">FILTER</span> (<span class="hljs-keyword">WHERE</span> outcome <span class="hljs-operator">&lt;&gt;</span> <span class="hljs-string">'success'</span>)::<span class="hljs-type">float</span>
         <span class="hljs-operator">/</span> <span class="hljs-built_in">NULLIF</span>(<span class="hljs-built_in">count</span>(<span class="hljs-operator">*</span>) <span class="hljs-keyword">FILTER</span> (<span class="hljs-keyword">WHERE</span> outcome <span class="hljs-operator">=</span> <span class="hljs-string">'success'</span>), <span class="hljs-number">0</span>) <span class="hljs-keyword">AS</span> fail_ratio,
       <span class="hljs-built_in">count</span>(<span class="hljs-operator">*</span>) <span class="hljs-keyword">FILTER</span> (<span class="hljs-keyword">WHERE</span> outcome <span class="hljs-operator">=</span> <span class="hljs-string">'unknown_user'</span>) <span class="hljs-keyword">AS</span> unknown_users
<span class="hljs-keyword">FROM</span> login_attempts
<span class="hljs-keyword">WHERE</span> ts <span class="hljs-operator">&gt;</span> now() <span class="hljs-operator">-</span> <span class="hljs-type">interval</span> <span class="hljs-string">'7 days'</span>
<span class="hljs-keyword">GROUP</span> <span class="hljs-keyword">BY</span> <span class="hljs-number">1</span> <span class="hljs-keyword">ORDER</span> <span class="hljs-keyword">BY</span> <span class="hljs-number">1</span>;
</code></pre></div>
<p>Sources trying many accounts:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">sql</span></div><pre><code class="language-sql hljs"><span class="hljs-keyword">SELECT</span> ip, <span class="hljs-built_in">count</span>(<span class="hljs-keyword">DISTINCT</span> account) <span class="hljs-keyword">AS</span> accounts, <span class="hljs-built_in">count</span>(<span class="hljs-operator">*</span>) <span class="hljs-keyword">AS</span> attempts,
       <span class="hljs-built_in">count</span>(<span class="hljs-operator">*</span>) <span class="hljs-keyword">FILTER</span> (<span class="hljs-keyword">WHERE</span> outcome <span class="hljs-operator">=</span> <span class="hljs-string">'success'</span>) <span class="hljs-keyword">AS</span> successes
<span class="hljs-keyword">FROM</span> login_attempts
<span class="hljs-keyword">WHERE</span> ts <span class="hljs-operator">&gt;</span> now() <span class="hljs-operator">-</span> <span class="hljs-type">interval</span> <span class="hljs-string">'1 hour'</span>
<span class="hljs-keyword">GROUP</span> <span class="hljs-keyword">BY</span> ip
<span class="hljs-keyword">HAVING</span> <span class="hljs-built_in">count</span>(<span class="hljs-keyword">DISTINCT</span> account) <span class="hljs-operator">&gt;</span> <span class="hljs-number">20</span>
<span class="hljs-keyword">ORDER</span> <span class="hljs-keyword">BY</span> accounts <span class="hljs-keyword">DESC</span>;
</code></pre></div>
<p>The <code>successes</code> column is your incident list: lock those accounts, force a reset, and review their activity since the login.</p>
<h2 id="how-to-stop-credential-stuffing-once-you-see-it">How to stop credential stuffing once you see it <a class="post-heading-anchor" href="#how-to-stop-credential-stuffing-once-you-see-it" aria-hidden="true">#</a></h2>
<ul>
<li>Step up successful logins from suspicious sources to a second factor or email confirmation instead of blocking the source.</li>
<li>Rate limit the login route per IP and per account, plus a global ceiling on unknown-user failures. <a href="/blog/nodejs-rate-limiting">Rate limiting algorithms compared</a> covers which one to pick.</li>
<li>Check new passwords against known breached passwords at signup and reset.</li>
<li>Require MFA for admin and API key management.</li>
</ul>
<p>For a one-person team, the afternoon version is the logging change, the second SQL query as a daily scheduled job, and a login rate limit. A ten-person team should add the in-process or Redis detector, alerting on the failure ratio, and a written runbook for locking compromised accounts.</p>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<p>Log every login attempt with an outcome of <code>success</code>, <code>bad_password</code> or <code>unknown_user</code>, an HMAC of the account and the client IP. Every other step on this page depends on it.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="how-do-i-know-if-my-api-is-under-a-credential-stuffing-attack">How do I know if my API is under a credential stuffing attack? <a class="post-heading-anchor" href="#how-do-i-know-if-my-api-is-under-a-credential-stuffing-attack" aria-hidden="true">#</a></h3>
<p>Compare today’s login failure ratio and unknown-account failures with your normal baseline, and look for single IPs or networks trying many distinct accounts. A sudden jump in any of those, especially with a few successes from the same sources, is the pattern.</p>
<h3 id="what-is-the-difference-between-credential-stuffing-and-brute-force">What is the difference between credential stuffing and brute force? <a class="post-heading-anchor" href="#what-is-the-difference-between-credential-stuffing-and-brute-force" aria-hidden="true">#</a></h3>
<p>Brute force guesses many passwords for one account. Credential stuffing tries one known password per account across many accounts, which is why per-account lockouts rarely catch it.</p>
<h3 id="does-rate-limiting-stop-credential-stuffing">Does rate limiting stop credential stuffing? <a class="post-heading-anchor" href="#does-rate-limiting-stop-credential-stuffing" aria-hidden="true">#</a></h3>
<p>It slows it down but rarely stops it, because attackers spread requests across many IPs. Combine rate limits with detection on distinct accounts per source and MFA.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not build this pipeline yourself, ApiTraffic captures every request to your login route from one middleware, with <code>Authorization</code>, <code>Cookie</code> and <code>X-Api-Key</code> masked by default. Add a <a href="https://www.apitraffic.io/docs/data/redactions">redaction rule</a> for the password field in the dashboard and it is pushed to your servers without a deploy.</p>
<ul>
<li><a href="https://www.apitraffic.io/docs/traffic/search">Search for bursts</a>: <code>url_path == "/v1/auth/login" AND status_code == 401</code>.</li>
<li>Behavioral protection scores each caller on signals including 401/403 counts and distinct endpoints, and gives callers over the threshold a temporary deny on every server.</li>
<li>A <a href="https://www.apitraffic.io/docs/waf/policies">rate limit on the login path</a> returns <code>429</code> with <code>Retry-After</code>, and access lists can deny by IP, CIDR, country or ASN. <a href="https://www.apitraffic.io/docs/waf/overview">Monitor mode</a> shows what a policy would block first.</li>
</ul>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260226-credential-stuffing-detection">apitraffic.io</a></p>
<p>Sources: <a href="https://www.verizon.com/business/resources/articles/credential-stuffing-attacks-2025-dbir-research/" target="_blank" rel="noopener noreferrer">Verizon, “Additional 2025 DBIR research on credential stuffing” (2025)</a>; <a href="https://cheatsheetseries.owasp.org/cheatsheets/Credential_Stuffing_Prevention_Cheat_Sheet.html" target="_blank" rel="noopener noreferrer">OWASP Credential Stuffing Prevention Cheat Sheet</a>; <a href="https://owasp.org/www-project-automated-threats-to-web-applications/" target="_blank" rel="noopener noreferrer">OWASP Automated Threats, OAT-008 Credential Stuffing</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>How to log outbound HTTP requests in Node.js</title>
      <link>https://www.apitraffic.io/blog/log-outbound-http-requests</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/log-outbound-http-requests</guid>
      <pubDate>Wed, 18 Feb 2026 14:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>Your API threw 502s because a provider was timing out, and your logs said nothing. Log outbound HTTP requests from fetch and axios with diagnostics_channel in an afternoon, then read error rate and p95 per host.</description>
      <content:encoded><![CDATA[<p>Your API returns 502s for twenty minutes, your own logs show nothing unusual, and the cause turns out to be a payment provider that started timing out. If you don’t log outbound HTTP requests, the third-party APIs your Node app depends on are invisible until a customer tells you.</p>
<p>The quickest way to log outbound HTTP requests in Node.js without touching every call site is <code>node:diagnostics_channel</code>. Node’s global <code>fetch</code> is built on undici, which <a href="https://undici.nodejs.org/api/DiagnosticsChannel" target="_blank" rel="noopener noreferrer">publishes <code>undici:request:*</code> events</a>; the <code>node:http</code> client <a href="https://nodejs.org/download/release/v24.9.0/docs/api/diagnostics_channel.html" target="_blank" rel="noopener noreferrer">publishes <code>http.client.*</code> events</a>, which covers axios and most older SDKs. Subscribe once at startup, record host, method, path, status and duration, and you get one log line per outbound call. This post builds that subscriber, then covers what to do with the data. To turn the same events into per-vendor p95, error rate and throttling numbers, see <a href="/blog/third-party-api-latency">measuring third-party API latency</a>.</p>
<h2 id="what-to-record-when-you-log-outbound-http-requests">What to record when you log outbound HTTP requests <a class="post-heading-anchor" href="#what-to-record-when-you-log-outbound-http-requests" aria-hidden="true">#</a></h2>
<div class="post-table"><table>
<thead>
<tr>
<th>Field</th>
<th>Why</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>host</code></td>
<td>Group by dependency: payments, email, search</td>
</tr>
<tr>
<td><code>method</code> and path without query string</td>
<td>Query strings carry tokens and IDs</td>
</tr>
<tr>
<td><code>status</code></td>
<td>Separate 4xx (your bug) from 5xx and 429 (theirs)</td>
</tr>
<tr>
<td><code>durationMs</code></td>
<td>Time to response headers, plus total if you need it</td>
</tr>
<tr>
<td><code>error</code> code</td>
<td><code>UND_ERR_CONNECT_TIMEOUT</code>, <code>ECONNRESET</code>, <code>ENOTFOUND</code> tell different stories</td>
</tr>
<tr>
<td>inbound request ID</td>
<td>Which of your requests waited on this call</td>
</tr>
</tbody>
</table></div>
<p>Leave out request and response bodies by default, and never log the outbound <code>Authorization</code> header: it’s your credential with that provider. The rules from <a href="/blog/redact-sensitive-data-logs">redacting sensitive data in logs</a> apply to outbound logs too.</p>
<h2 id="how-to-log-fetch-requests-with-diagnostics-channel">How to log fetch requests with diagnostics_channel <a class="post-heading-anchor" href="#how-to-log-fetch-requests-with-diagnostics-channel" aria-hidden="true">#</a></h2>
<p>Undici publishes <code>undici:request:create</code> before dispatch, <code>undici:request:headers</code> when response headers arrive, <code>undici:request:trailers</code> when the body completes, and <code>undici:request:error</code> on failure. The same <code>request</code> object is passed to each, so a <code>WeakMap</code> can hold the start time.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// outbound-log.js</span>
<span class="hljs-keyword">import</span> diagnosticsChannel <span class="hljs-keyword">from</span> <span class="hljs-string">'node:diagnostics_channel'</span>;
<span class="hljs-keyword">import</span> { logger } <span class="hljs-keyword">from</span> <span class="hljs-string">'./logger.js'</span>;
<span class="hljs-keyword">import</span> { getRequestId } <span class="hljs-keyword">from</span> <span class="hljs-string">'./context.js'</span>;

<span class="hljs-keyword">const</span> started = <span class="hljs-keyword">new</span> <span class="hljs-title">WeakMap</span>();

<span class="hljs-keyword">function</span> <span class="hljs-title">pathOnly</span>(<span class="hljs-params">path</span>) {
  <span class="hljs-keyword">const</span> i = path.<span class="hljs-title">indexOf</span>(<span class="hljs-string">'?'</span>);
  <span class="hljs-keyword">return</span> i === -<span class="hljs-number">1</span> ? path : path.<span class="hljs-title">slice</span>(<span class="hljs-number">0</span>, i);
}

diagnosticsChannel.<span class="hljs-title">subscribe</span>(<span class="hljs-string">'undici:request:create'</span>, <span class="hljs-function">(<span class="hljs-params">{ request }</span>) =&gt;</span> {
  started.<span class="hljs-title">set</span>(request, { <span class="hljs-attr">t</span>: performance.<span class="hljs-title">now</span>(), <span class="hljs-attr">requestId</span>: <span class="hljs-title">getRequestId</span>() });
});

diagnosticsChannel.<span class="hljs-title">subscribe</span>(<span class="hljs-string">'undici:request:headers'</span>, <span class="hljs-function">(<span class="hljs-params">{ request, response }</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> s = started.<span class="hljs-title">get</span>(request);
  <span class="hljs-keyword">if</span> (!s) <span class="hljs-keyword">return</span>;
  s.<span class="hljs-property">status</span> = response.<span class="hljs-property">statusCode</span>;
  s.<span class="hljs-property">ttfbMs</span> = <span class="hljs-title">Math</span>.<span class="hljs-title">round</span>(performance.<span class="hljs-title">now</span>() - s.<span class="hljs-property">t</span>);
});

diagnosticsChannel.<span class="hljs-title">subscribe</span>(<span class="hljs-string">'undici:request:trailers'</span>, <span class="hljs-function">(<span class="hljs-params">{ request }</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> s = started.<span class="hljs-title">get</span>(request);
  <span class="hljs-keyword">if</span> (!s) <span class="hljs-keyword">return</span>;
  logger.<span class="hljs-title">info</span>({
    <span class="hljs-attr">outbound</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">host</span>: <span class="hljs-keyword">new</span> <span class="hljs-title">URL</span>(request.<span class="hljs-property">origin</span>).<span class="hljs-property">host</span>,
    <span class="hljs-attr">method</span>: request.<span class="hljs-property">method</span>,
    <span class="hljs-attr">path</span>: <span class="hljs-title">pathOnly</span>(request.<span class="hljs-property">path</span>),
    <span class="hljs-attr">status</span>: s.<span class="hljs-property">status</span>,
    <span class="hljs-attr">ttfbMs</span>: s.<span class="hljs-property">ttfbMs</span>,
    <span class="hljs-attr">durationMs</span>: <span class="hljs-title">Math</span>.<span class="hljs-title">round</span>(performance.<span class="hljs-title">now</span>() - s.<span class="hljs-property">t</span>),
    <span class="hljs-attr">requestId</span>: s.<span class="hljs-property">requestId</span>,
  }, <span class="hljs-string">'outbound request'</span>);
  started.<span class="hljs-title">delete</span>(request);
});

diagnosticsChannel.<span class="hljs-title">subscribe</span>(<span class="hljs-string">'undici:request:error'</span>, <span class="hljs-function">(<span class="hljs-params">{ request, error }</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> s = started.<span class="hljs-title">get</span>(request);
  logger.<span class="hljs-title">warn</span>({
    <span class="hljs-attr">outbound</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">host</span>: <span class="hljs-keyword">new</span> <span class="hljs-title">URL</span>(request.<span class="hljs-property">origin</span>).<span class="hljs-property">host</span>,
    <span class="hljs-attr">method</span>: request.<span class="hljs-property">method</span>,
    <span class="hljs-attr">path</span>: <span class="hljs-title">pathOnly</span>(request.<span class="hljs-property">path</span>),
    <span class="hljs-attr">error</span>: error.<span class="hljs-property">code</span> ?? error.<span class="hljs-property">name</span>,
    <span class="hljs-attr">durationMs</span>: s ? <span class="hljs-title">Math</span>.<span class="hljs-title">round</span>(performance.<span class="hljs-title">now</span>() - s.<span class="hljs-property">t</span>) : <span class="hljs-literal">undefined</span>,
    <span class="hljs-attr">requestId</span>: s?.<span class="hljs-property">requestId</span>,
  }, <span class="hljs-string">'outbound request failed'</span>);
  started.<span class="hljs-title">delete</span>(request);
});
</code></pre></div>
<p>Import it once, before your app starts making calls:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// server.js</span>
<span class="hljs-keyword">import</span> <span class="hljs-string">'./outbound-log.js'</span>;
<span class="hljs-keyword">import</span> { app } <span class="hljs-keyword">from</span> <span class="hljs-string">'./app.js'</span>;
app.<span class="hljs-title">listen</span>(<span class="hljs-number">3000</span>);
</code></pre></div>
<p>Three details matter. Subscribers run synchronously inside undici, so keep them fast and make sure they can’t throw. The <code>trailers</code> event only fires once the response body has been consumed; if your code never reads a body, you’ll get the headers event but no completion line, which is itself a bug worth finding. And the <code>requestId</code> comes from the <a href="/blog/asynclocalstorage-request-id">AsyncLocalStorage request ID setup</a>, captured at <code>create</code> time while the inbound request’s context is active.</p>
<h2 id="how-to-log-axios-and-http-request-calls">How to log axios and http.request calls <a class="post-heading-anchor" href="#how-to-log-axios-and-http-request-calls" aria-hidden="true">#</a></h2>
<p>Libraries built on <code>node:http</code>, including axios’s Node adapter, don’t go through undici. Node publishes <code>http.client.request.start</code>, <code>http.client.response.finish</code> and <code>http.client.request.error</code> for those:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">const</span> httpStarted = <span class="hljs-keyword">new</span> <span class="hljs-title">WeakMap</span>();

diagnosticsChannel.<span class="hljs-title">subscribe</span>(<span class="hljs-string">'http.client.request.start'</span>, <span class="hljs-function">(<span class="hljs-params">{ request }</span>) =&gt;</span> {
  httpStarted.<span class="hljs-title">set</span>(request, { <span class="hljs-attr">t</span>: performance.<span class="hljs-title">now</span>(), <span class="hljs-attr">requestId</span>: <span class="hljs-title">getRequestId</span>() });
});

diagnosticsChannel.<span class="hljs-title">subscribe</span>(<span class="hljs-string">'http.client.response.finish'</span>, <span class="hljs-function">(<span class="hljs-params">{ request, response }</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> s = httpStarted.<span class="hljs-title">get</span>(request);
  <span class="hljs-keyword">if</span> (!s) <span class="hljs-keyword">return</span>;
  logger.<span class="hljs-title">info</span>({
    <span class="hljs-attr">outbound</span>: <span class="hljs-literal">true</span>,
    <span class="hljs-attr">host</span>: request.<span class="hljs-property">host</span>,
    <span class="hljs-attr">method</span>: request.<span class="hljs-property">method</span>,
    <span class="hljs-attr">path</span>: <span class="hljs-title">pathOnly</span>(request.<span class="hljs-property">path</span>),
    <span class="hljs-attr">status</span>: response.<span class="hljs-property">statusCode</span>,
    <span class="hljs-attr">durationMs</span>: <span class="hljs-title">Math</span>.<span class="hljs-title">round</span>(performance.<span class="hljs-title">now</span>() - s.<span class="hljs-property">t</span>),
    <span class="hljs-attr">requestId</span>: s.<span class="hljs-property">requestId</span>,
  }, <span class="hljs-string">'outbound request'</span>);
  httpStarted.<span class="hljs-title">delete</span>(request);
});
</code></pre></div>
<p>The <a href="https://nodejs.org/download/release/v24.9.0/docs/api/diagnostics_channel.html" target="_blank" rel="noopener noreferrer">Node.js diagnostics_channel docs</a> mark the built-in HTTP channels and the undici channels as experimental, so pin a test that asserts a log line appears for a known call. If a Node upgrade changes an event shape, you’ll find out in CI.</p>
<h2 id="the-wrapper-alternative">The wrapper alternative <a class="post-heading-anchor" href="#the-wrapper-alternative" aria-hidden="true">#</a></h2>
<p>If diagnostics channels feel too low-level, a wrapper around <code>fetch</code> works when all calls go through your code:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title">loggedFetch</span>(<span class="hljs-params">url, init</span>) {
  <span class="hljs-keyword">const</span> t = performance.<span class="hljs-title">now</span>();
  <span class="hljs-keyword">const</span> u = <span class="hljs-keyword">new</span> <span class="hljs-title">URL</span>(url);
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title">fetch</span>(url, init);
    logger.<span class="hljs-title">info</span>({ <span class="hljs-attr">outbound</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">host</span>: u.<span class="hljs-property">host</span>, <span class="hljs-attr">path</span>: u.<span class="hljs-property">pathname</span>, <span class="hljs-attr">status</span>: res.<span class="hljs-property">status</span>,
      <span class="hljs-attr">durationMs</span>: <span class="hljs-title">Math</span>.<span class="hljs-title">round</span>(performance.<span class="hljs-title">now</span>() - t) }, <span class="hljs-string">'outbound request'</span>);
    <span class="hljs-keyword">return</span> res;
  } <span class="hljs-keyword">catch</span> (err) {
    logger.<span class="hljs-title">warn</span>({ <span class="hljs-attr">outbound</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">host</span>: u.<span class="hljs-property">host</span>, <span class="hljs-attr">path</span>: u.<span class="hljs-property">pathname</span>, <span class="hljs-attr">error</span>: err.<span class="hljs-property">name</span> }, <span class="hljs-string">'outbound request failed'</span>);
    <span class="hljs-keyword">throw</span> err;
  }
}
</code></pre></div>
<p>The catch is coverage: vendor SDKs make their own calls and won’t use your wrapper. For a 1-person team with two dependencies, the wrapper is fine. A 10-person team with a dozen SDKs should use the channels.</p>
<h2 id="what-to-look-for-in-outbound-logs">What to look for in outbound logs <a class="post-heading-anchor" href="#what-to-look-for-in-outbound-logs" aria-hidden="true">#</a></h2>
<p>Once a few days of outbound logs exist, these questions are worth answering per host (for per-vendor metrics, timeouts and alerting built on them, see <a href="/blog/third-party-api-latency">third-party API latency per vendor</a>):</p>
<ul>
<li><strong>Error rate by host.</strong> A provider at 2% 5xx explains a lot of “random” failures in your own error rate.</li>
<li><strong>p95 duration by host.</strong> Compare it with the timeout you set. If p95 is 4 seconds and your timeout is 5, you’re one bad day from cascading timeouts. <a href="/blog/nodejs-fetch-timeout">Timeouts with fetch and AbortSignal</a> covers setting them.</li>
<li><strong>429s and <code>Retry-After</code>.</strong> Retrying faster than the provider allows makes the limit last longer.</li>
<li><strong>Calls per inbound request.</strong> Ten calls to the same host for one inbound request usually means a loop that should be a batch call.</li>
<li><strong>Connect errors vs response errors.</strong> <code>ENOTFOUND</code> and connect timeouts point at DNS or network; 5xx points at the provider.</li>
<li><strong>Time of day.</strong> Provider slowdowns often line up with their batch jobs or your traffic peaks. A chart of p95 per host per hour makes the pattern obvious, and gives you something concrete to send their support team.</li>
</ul>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<p>Add the undici subscriber above, log host, path, status and duration, and look at p95 and error rate per host once a week. That alone turns “the API is flaky” into “the email provider’s p95 tripled on Tuesday”.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="does-nodes-fetch-log-requests-by-default">Does Node’s fetch log requests by default? <a class="post-heading-anchor" href="#does-nodes-fetch-log-requests-by-default" aria-hidden="true">#</a></h3>
<p>No. Node’s <code>fetch</code> doesn’t log anything. You need a diagnostics channel subscriber, a wrapper or an instrumentation library such as <a href="https://www.npmjs.com/package/@opentelemetry/instrumentation-undici" target="_blank" rel="noopener noreferrer">OpenTelemetry’s undici instrumentation</a>.</p>
<h3 id="will-diagnostics-channel-slow-down-my-outbound-calls">Will diagnostics_channel slow down my outbound calls? <a class="post-heading-anchor" href="#will-diagnostics-channel-slow-down-my-outbound-calls" aria-hidden="true">#</a></h3>
<p>When nobody subscribes, publishing costs almost nothing. With a subscriber, the cost is whatever your handler does, so keep it to building a small object and handing it to an async logger.</p>
<h3 id="how-do-i-log-outbound-request-bodies-safely">How do I log outbound request bodies safely? <a class="post-heading-anchor" href="#how-do-i-log-outbound-request-bodies-safely" aria-hidden="true">#</a></h3>
<p>Do it per host and per path, with an allowlist of fields, and only while debugging a specific integration. Bodies sent to providers often contain personal data and credentials.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you’d rather not build and maintain this, ApiTraffic <a href="https://www.apitraffic.io/docs/outbound-requests">logs outbound requests</a> alongside inbound ones from the same SDK. <a href="https://www.apitraffic.io/docs/traffic/search">Search them</a> like any other traffic, for example <code>direction == "out" AND status_code &gt;= 500</code> for failing calls to providers.</p>
<p>With the <a href="https://www.apitraffic.io/docs/apm/setup">APM preload</a> (<code>node -r @apitraffic/node/register ./server.js</code>), outbound <code>fetch</code> calls are recorded as spans inside the inbound request that started them, so a request’s waterfall shows exactly which third-party call it was waiting on, and endpoint details show p50, p95 and p99 over time.</p>
<p>Start free and see every call your API makes and receives: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260218-log-outbound-http-requests">apitraffic.io</a></p>
<p>Sources: <a href="https://nodejs.org/download/release/v24.9.0/docs/api/diagnostics_channel.html" target="_blank" rel="noopener noreferrer">Node.js docs: diagnostics_channel, built-in HTTP channels</a>; <a href="https://undici.nodejs.org/api/DiagnosticsChannel" target="_blank" rel="noopener noreferrer">undici Diagnostics Channel docs</a>; <a href="https://www.npmjs.com/package/@opentelemetry/instrumentation-undici" target="_blank" rel="noopener noreferrer">OpenTelemetry undici instrumentation on npm</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Node.js fetch timeout: deadlines with AbortSignal and undici</title>
      <link>https://www.apitraffic.io/blog/nodejs-fetch-timeout</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/nodejs-fetch-timeout</guid>
      <pubDate>Mon, 09 Feb 2026 14:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>One slow vendor and every handler hangs, because Node fetch waits up to five minutes by default. Here is how to set deadlines with AbortSignal and undici in Express and Fastify.</description>
      <content:encoded><![CDATA[<p>A payment or shipping vendor has a bad afternoon, and your API stops answering: every handler is sitting on an outbound call that never returns. Node’s built-in <code>fetch()</code> has no overall timeout by default, and undici, the client underneath it, <a href="https://undici.nodejs.org/api/Client" target="_blank" rel="noopener noreferrer">waits up to 300 seconds for response headers</a>. To set a Node.js fetch timeout, pass <code>signal: AbortSignal.timeout(ms)</code> on every call, combine it with the client’s disconnect using <code>AbortSignal.any()</code>, and tune undici’s connection defaults if you need to.</p>
<p>This post covers the defaults Node and undici actually use, code for outbound and inbound timeouts in <a href="https://www.apitraffic.io/express">Express</a> and <a href="https://www.apitraffic.io/fastify">Fastify</a>, and how to pick the numbers.</p>
<h2 id="what-are-the-default-timeouts-in-node-js-fetch-and-undici">What are the default timeouts in Node.js fetch and undici? <a class="post-heading-anchor" href="#what-are-the-default-timeouts-in-node-js-fetch-and-undici" aria-hidden="true">#</a></h2>
<div class="post-table"><table>
<thead>
<tr>
<th>Setting</th>
<th>Default</th>
<th>What it limits</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>connectTimeout</code></td>
<td>10 seconds</td>
<td>Opening the TCP/TLS connection</td>
</tr>
<tr>
<td><code>headersTimeout</code></td>
<td>300 seconds</td>
<td>Waiting for response headers</td>
</tr>
<tr>
<td><code>bodyTimeout</code></td>
<td>300 seconds</td>
<td>Time between body chunks</td>
</tr>
<tr>
<td>Overall deadline</td>
<td>none</td>
<td></td>
</tr>
</tbody>
</table></div>
<p>A vendor that accepts the connection and then stalls holds your handler, your client’s connection and any database connection you have checked out for minutes.</p>
<p>Inbound, <a href="https://nodejs.org/api/http.html" target="_blank" rel="noopener noreferrer">Node’s <code>http.Server</code> defaults</a> are <code>requestTimeout</code> 300 seconds (since Node 18), <code>headersTimeout</code> the lower of 60 seconds and <code>requestTimeout</code>, and <code>keepAliveTimeout</code> 5 seconds. <a href="https://fastify.dev/docs/latest/Reference/Server/" target="_blank" rel="noopener noreferrer">Fastify sets</a> <code>requestTimeout</code> and <code>connectionTimeout</code> to 0 (no limit) and <code>keepAliveTimeout</code> to 72 seconds.</p>
<h2 id="how-to-set-a-node-js-fetch-timeout-with-abortsignal">How to set a Node.js fetch timeout with AbortSignal <a class="post-heading-anchor" href="#how-to-set-a-node-js-fetch-timeout-with-abortsignal" aria-hidden="true">#</a></h2>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title">fetch</span>(<span class="hljs-string">'https://api.vendor.example/v1/quotes'</span>, {
  <span class="hljs-attr">method</span>: <span class="hljs-string">'POST'</span>,
  <span class="hljs-attr">headers</span>: { <span class="hljs-string">'content-type'</span>: <span class="hljs-string">'application/json'</span> },
  <span class="hljs-attr">body</span>: <span class="hljs-title">JSON</span>.<span class="hljs-title">stringify</span>(payload),
  <span class="hljs-attr">signal</span>: <span class="hljs-title">AbortSignal</span>.<span class="hljs-title">timeout</span>(<span class="hljs-number">3_000</span>),
});
<span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.<span class="hljs-title">json</span>();
</code></pre></div>
<p>The signal covers the whole exchange, including reading the body, as long as you read it before the timer fires. On timeout, <code>fetch</code> rejects with a <code>DOMException</code> named <code>'TimeoutError'</code>; a manual <code>controller.abort()</code> gives <code>'AbortError'</code>:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">try</span> {
  <span class="hljs-keyword">await</span> <span class="hljs-title">fetch</span>(url, { signal });
} <span class="hljs-keyword">catch</span> (err) {
  <span class="hljs-keyword">if</span> (err.<span class="hljs-property">name</span> === <span class="hljs-string">'TimeoutError'</span>) { <span class="hljs-comment">/* our deadline */</span> }
  <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (err.<span class="hljs-property">name</span> === <span class="hljs-string">'AbortError'</span>) { <span class="hljs-comment">/* cancelled, e.g. client went away */</span> }
  <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (err.<span class="hljs-property">cause</span>?.<span class="hljs-property">code</span>) { <span class="hljs-comment">/* ECONNREFUSED, UND_ERR_CONNECT_TIMEOUT, ... */</span> }
  <span class="hljs-keyword">throw</span> err;
}
</code></pre></div>
<p>Network failures from built-in fetch arrive as <code>TypeError: fetch failed</code> with the real reason in <code>err.cause</code>. Log <code>err.cause.code</code>; the top-level message tells you nothing.</p>
<h2 id="cancel-outbound-calls-when-the-client-disconnects">Cancel outbound calls when the client disconnects <a class="post-heading-anchor" href="#cancel-outbound-calls-when-the-client-disconnects" aria-hidden="true">#</a></h2>
<p>If your caller has gone, stop waiting on the vendor for them. <a href="https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static" target="_blank" rel="noopener noreferrer"><code>AbortSignal.any()</code></a> (Node 20.3+) aborts when any of its signals does:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">app.<span class="hljs-title">get</span>(<span class="hljs-string">'/quote'</span>, <span class="hljs-title">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> clientGone = <span class="hljs-keyword">new</span> <span class="hljs-title">AbortController</span>();
  res.<span class="hljs-title">on</span>(<span class="hljs-string">'close'</span>, <span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">if</span> (!res.<span class="hljs-property">writableFinished</span>) clientGone.<span class="hljs-title">abort</span>();
  });

  <span class="hljs-keyword">const</span> signal = <span class="hljs-title">AbortSignal</span>.<span class="hljs-title">any</span>([clientGone.<span class="hljs-property">signal</span>, <span class="hljs-title">AbortSignal</span>.<span class="hljs-title">timeout</span>(<span class="hljs-number">3_000</span>)]);
  <span class="hljs-keyword">const</span> upstream = <span class="hljs-keyword">await</span> <span class="hljs-title">fetch</span>(<span class="hljs-string">'https://api.vendor.example/v1/quotes'</span>, { signal });
  res.<span class="hljs-title">json</span>(<span class="hljs-keyword">await</span> upstream.<span class="hljs-title">json</span>());
});
</code></pre></div>
<p>Listen on <code>res</code> for <code>close</code>, not <code>req</code>: in current Node versions <code>req</code> emits <code>close</code> once the body has been read, which happens on every normal request.</p>
<h2 id="deadline-budgets-across-several-calls">Deadline budgets across several calls <a class="post-heading-anchor" href="#deadline-budgets-across-several-calls" aria-hidden="true">#</a></h2>
<p>Three calls with 3-second timeouts can take 9 seconds. If the endpoint should answer within 5, give each call what is left of a shared budget:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">deadline</span>(<span class="hljs-params">ms, parent</span>) {
  <span class="hljs-keyword">const</span> end = performance.<span class="hljs-title">now</span>() + ms;
  <span class="hljs-keyword">return</span> {
    <span class="hljs-attr">remaining</span>: <span class="hljs-function">() =&gt;</span> <span class="hljs-title">Math</span>.<span class="hljs-title">max</span>(<span class="hljs-number">0</span>, end - performance.<span class="hljs-title">now</span>()),
    <span class="hljs-title">signal</span>(<span class="hljs-params">capMs = <span class="hljs-literal">Infinity</span></span>) {
      <span class="hljs-keyword">const</span> t = <span class="hljs-title">AbortSignal</span>.<span class="hljs-title">timeout</span>(<span class="hljs-title">Math</span>.<span class="hljs-title">max</span>(<span class="hljs-number">1</span>, <span class="hljs-title">Math</span>.<span class="hljs-title">min</span>(capMs, end - performance.<span class="hljs-title">now</span>())));
      <span class="hljs-keyword">return</span> parent ? <span class="hljs-title">AbortSignal</span>.<span class="hljs-title">any</span>([parent, t]) : t;
    },
  };
}

app.<span class="hljs-title">get</span>(<span class="hljs-string">'/checkout/summary'</span>, <span class="hljs-title">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> d = <span class="hljs-title">deadline</span>(<span class="hljs-number">5_000</span>);
  <span class="hljs-keyword">const</span> cart = <span class="hljs-keyword">await</span> <span class="hljs-title">fetch</span>(cartUrl, { <span class="hljs-attr">signal</span>: d.<span class="hljs-title">signal</span>(<span class="hljs-number">2_000</span>) }).<span class="hljs-title">then</span>(<span class="hljs-function">(<span class="hljs-params">r</span>) =&gt;</span> r.<span class="hljs-title">json</span>());
  <span class="hljs-keyword">const</span> tax = <span class="hljs-keyword">await</span> <span class="hljs-title">fetch</span>(taxUrl, { <span class="hljs-attr">signal</span>: d.<span class="hljs-title">signal</span>(<span class="hljs-number">2_000</span>) }).<span class="hljs-title">then</span>(<span class="hljs-function">(<span class="hljs-params">r</span>) =&gt;</span> r.<span class="hljs-title">json</span>());
  <span class="hljs-keyword">const</span> ship = d.<span class="hljs-title">remaining</span>() &gt; <span class="hljs-number">500</span>
    ? <span class="hljs-keyword">await</span> <span class="hljs-title">fetch</span>(shipUrl, { <span class="hljs-attr">signal</span>: d.<span class="hljs-title">signal</span>() }).<span class="hljs-title">then</span>(<span class="hljs-function">(<span class="hljs-params">r</span>) =&gt;</span> r.<span class="hljs-title">json</span>())
    : <span class="hljs-literal">null</span>; <span class="hljs-comment">// skip the optional call rather than blow the budget</span>
  res.<span class="hljs-title">json</span>({ cart, tax, ship });
});
</code></pre></div>
<h2 id="how-to-change-undici-timeout-defaults">How to change undici timeout defaults <a class="post-heading-anchor" href="#how-to-change-undici-timeout-defaults" aria-hidden="true">#</a></h2>
<p><code>AbortSignal</code> sets a deadline per call. To change connection-level defaults for every call, install undici and configure an <code>Agent</code>:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> { <span class="hljs-title">Agent</span>, fetch, setGlobalDispatcher } <span class="hljs-keyword">from</span> <span class="hljs-string">'undici'</span>;

<span class="hljs-keyword">const</span> agent = <span class="hljs-keyword">new</span> <span class="hljs-title">Agent</span>({
  <span class="hljs-attr">connectTimeout</span>: <span class="hljs-number">2_000</span>,
  <span class="hljs-attr">headersTimeout</span>: <span class="hljs-number">10_000</span>,
  <span class="hljs-attr">bodyTimeout</span>: <span class="hljs-number">10_000</span>,
});

<span class="hljs-title">setGlobalDispatcher</span>(agent); <span class="hljs-comment">// also affects global fetch()</span>

<span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title">fetch</span>(url, { <span class="hljs-attr">dispatcher</span>: agent, <span class="hljs-attr">signal</span>: <span class="hljs-title">AbortSignal</span>.<span class="hljs-title">timeout</span>(<span class="hljs-number">3_000</span>) });
</code></pre></div>
<p>The npm package and the copy bundled in Node can be different versions. Pin the package to the major version your Node bundles, or use undici’s <code>fetch</code> consistently. Undici’s timeout errors carry codes such as <code>UND_ERR_CONNECT_TIMEOUT</code>, <code>UND_ERR_HEADERS_TIMEOUT</code> and <code>UND_ERR_BODY_TIMEOUT</code>.</p>
<h2 id="server-side-request-timeouts-in-express-and-fastify">Server-side request timeouts in Express and Fastify <a class="post-heading-anchor" href="#server-side-request-timeouts-in-express-and-fastify" aria-hidden="true">#</a></h2>
<p>Outbound timeouts protect you from dependencies; inbound ones protect you from clients.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">const</span> server = app.<span class="hljs-title">listen</span>(<span class="hljs-number">3000</span>);
server.<span class="hljs-property">requestTimeout</span> = <span class="hljs-number">30_000</span>;   <span class="hljs-comment">// whole request within 30s</span>
server.<span class="hljs-property">headersTimeout</span> = <span class="hljs-number">10_000</span>;   <span class="hljs-comment">// headers within 10s</span>
server.<span class="hljs-property">keepAliveTimeout</span> = <span class="hljs-number">65_000</span>; <span class="hljs-comment">// longer than the load balancer's idle timeout</span>
</code></pre></div>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// Fastify</span>
<span class="hljs-keyword">const</span> app = <span class="hljs-title">Fastify</span>({ <span class="hljs-attr">requestTimeout</span>: <span class="hljs-number">30_000</span>, <span class="hljs-attr">keepAliveTimeout</span>: <span class="hljs-number">65_000</span> });
</code></pre></div>
<p>The <code>keepAliveTimeout</code> line prevents a classic intermittent 502. If the load balancer keeps idle connections open longer than Node does, Node can close one just as the load balancer sends a request on it. <a href="https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#connection-idle-timeout" target="_blank" rel="noopener noreferrer">AWS Application Load Balancers default to a 60-second idle timeout</a>, hence 65 above; check your own proxy or platform rather than copying the number. These settings also interact with <a href="/blog/nodejs-graceful-shutdown">graceful shutdown</a>: your drain deadline should cover your longest allowed request.</p>
<p>For databases, set the client’s own limits too: with <code>pg</code>, <code>statement_timeout</code> and the pool’s <code>connectionTimeoutMillis</code>; for Redis, connect and command timeouts. For vendor SDKs, check whether they accept a <code>signal</code> or <code>timeout</code> option.</p>
<h2 id="how-to-pick-timeout-values">How to pick timeout values <a class="post-heading-anchor" href="#how-to-pick-timeout-values" aria-hidden="true">#</a></h2>
<ul>
<li><strong>Per call:</strong> a little above the dependency’s observed p99. If a vendor’s p99 is 800 ms, 2 seconds loses almost nothing. Read <a href="/blog/api-latency-percentiles">p95 and p99 correctly</a> before you pick.</li>
<li><strong>Per endpoint:</strong> what your caller tolerates. If the frontend gives up at 10 seconds, finishing at 11 helps nobody.</li>
<li><strong>Retries:</strong> idempotent calls only, with backoff, inside the endpoint’s budget.</li>
<li><strong>Nesting:</strong> client &gt; load balancer &gt; handler &gt; each dependency.</li>
</ul>
<p>A solo developer can start with <code>AbortSignal.timeout(5_000)</code> on every outbound call and the three server settings above. A 10-person team should put a <code>deadline()</code> helper and a configured <code>Agent</code> in a shared module so every service uses the same defaults.</p>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<ul>
<li><code>grep -rn "fetch(" src/</code> and add <code>signal: AbortSignal.timeout(...)</code> to every call that lacks one.</li>
<li>Log <code>err.cause?.code</code> when fetch fails.</li>
<li>Set <code>keepAliveTimeout</code> above your load balancer’s idle timeout.</li>
<li>Make your <a href="/blog/nodejs-health-check">readiness check</a> use its own short timeout, so a slow dependency cannot hang it.</li>
</ul>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="does-node-js-fetch-have-a-default-timeout">Does Node.js fetch have a default timeout? <a class="post-heading-anchor" href="#does-node-js-fetch-have-a-default-timeout" aria-hidden="true">#</a></h3>
<p>Not an overall one. Undici, which powers it, defaults to a 10-second connect timeout and 300 seconds each for headers and between body chunks. Add <code>AbortSignal.timeout()</code> to set a real deadline.</p>
<h3 id="what-is-the-difference-between-timeouterror-and-aborterror">What is the difference between TimeoutError and AbortError? <a class="post-heading-anchor" href="#what-is-the-difference-between-timeouterror-and-aborterror" aria-hidden="true">#</a></h3>
<p><code>AbortSignal.timeout()</code> rejects with a <code>DOMException</code> named <code>TimeoutError</code>. Calling <code>abort()</code> on an <code>AbortController</code> gives <code>AbortError</code>. Checking <code>err.name</code> lets you treat a deadline differently from a cancellation.</p>
<h3 id="how-do-i-set-a-request-timeout-in-express">How do I set a request timeout in Express? <a class="post-heading-anchor" href="#how-do-i-set-a-request-timeout-in-express" aria-hidden="true">#</a></h3>
<p>Set <code>server.requestTimeout</code> and <code>server.headersTimeout</code> on the server returned by <code>app.listen()</code>. For per-route deadlines, create an <code>AbortSignal</code> in the handler and pass it to every outbound call.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>Picking timeouts needs data on how slow each dependency really is. If you would rather not build that instrumentation yourself, ApiTraffic <a href="https://www.apitraffic.io/docs/outbound-requests">logs outbound requests</a> alongside inbound ones, and its <a href="https://www.apitraffic.io/docs/apm/overview">APM</a> records each outbound <code>fetch</code> as a span attributed to the request that started it.</p>
<ul>
<li>The request waterfall shows every query and outbound call on one request’s timeline, so you can see which dependency spent the budget.</li>
<li><a href="https://www.apitraffic.io/docs/endpoint-handling">Endpoint details</a> give p50, p95 and p99 per endpoint, which is where your deadlines should come from.</li>
<li>After adding timeouts, search <code>status_code == 504</code> (or whatever you return on a deadline) to see how often they fire.</li>
</ul>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260209-nodejs-fetch-timeout">apitraffic.io</a></p>
<p>Sources: <a href="https://undici.nodejs.org/api/Client" target="_blank" rel="noopener noreferrer">undici Client API</a> ; <a href="https://nodejs.org/api/http.html" target="_blank" rel="noopener noreferrer">Node.js HTTP docs</a> ; <a href="https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static" target="_blank" rel="noopener noreferrer">MDN: AbortSignal.any()</a> ; <a href="https://fastify.dev/docs/latest/Reference/Server/" target="_blank" rel="noopener noreferrer">Fastify Server reference</a> ; <a href="https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#connection-idle-timeout" target="_blank" rel="noopener noreferrer">AWS ALB connection idle timeout</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Node.js rate limiting: fixed window vs sliding window vs token bucket</title>
      <link>https://www.apitraffic.io/blog/nodejs-rate-limiting</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/nodejs-rate-limiting</guid>
      <pubDate>Mon, 02 Feb 2026 14:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>One client&apos;s runaway retry loop should not take your API down at 3am. Compare fixed window, sliding window and token bucket rate limiting in Node, with working code, a Redis version and what to ship this afternoon.</description>
      <content:encoded><![CDATA[<p>One customer’s retry loop goes wrong at 3am, sends 40 requests a second, and takes your API down for everyone else. Node.js rate limiting is the fix, and the algorithm you pick decides whether a legitimate burst gets through, whether an abusive client slips past at the window boundary, and how accurate your <code>Retry-After</code> header is.</p>
<p>The direct answer: use a <strong>token bucket</strong> for per-client API limits (it allows short bursts and enforces a steady rate), a <strong>sliding window counter</strong> if you want simple “N per minute” semantics without boundary bursts, and a <strong>fixed window</strong> only for coarse daily or monthly quotas. Return <code>429</code> with <code>Retry-After</code>, and store state in Redis once you run more than one instance.</p>
<h2 id="before-the-algorithm-what-to-key-on-and-what-to-return">Before the algorithm: what to key on and what to return <a class="post-heading-anchor" href="#before-the-algorithm-what-to-key-on-and-what-to-return" aria-hidden="true">#</a></h2>
<p><strong>What are you limiting?</strong> The key can be the client IP, an API key, a user ID, or any of those combined with the route. IP is the only option for unauthenticated endpoints, but it is a weak identity: many users share one IP behind corporate NAT or mobile carriers, and one client can rotate through many IPv6 addresses. For authenticated APIs, key on the API key or account and keep a looser IP limit in front.</p>
<p><strong>What does the caller see?</strong> <code>429 Too Many Requests</code> (<a href="https://www.rfc-editor.org/rfc/rfc6585" target="_blank" rel="noopener noreferrer">RFC 6585</a>) with a <code>Retry-After</code> header in seconds (<a href="https://www.rfc-editor.org/rfc/rfc9110#field.retry-after" target="_blank" rel="noopener noreferrer">RFC 9110</a>). Well-behaved clients and SDKs back off when they see it.</p>
<p><strong>Where is the client IP?</strong> On Render, Railway, <a href="http://Fly.io" target="_blank" rel="noopener noreferrer">Fly.io</a>, Heroku or behind an AWS load balancer, <code>req.ip</code> is the proxy’s address unless you set Express’s <code>trust proxy</code> (or Fastify’s <code>trustProxy</code>) to match your proxy chain. Get this wrong and you rate limit your load balancer, which means everyone at once.</p>
<h2 id="fixed-window-rate-limiting-and-the-boundary-problem">Fixed window rate limiting and the boundary problem <a class="post-heading-anchor" href="#fixed-window-rate-limiting-and-the-boundary-problem" aria-hidden="true">#</a></h2>
<p>Count requests per key in the current window (each calendar minute, say) and reset when the window changes.</p>
<p>The weakness is the boundary. With a limit of 100 per minute, a client can send 100 requests at 0:59.9 and another 100 at 1:00.1. Both windows are within budget, and 200 requests arrived in 200 ms. Fixed windows are fine for “10,000 requests per day” quotas; they are a poor fit for protecting a fragile endpoint.</p>
<h2 id="how-the-sliding-window-counter-works">How the sliding window counter works <a class="post-heading-anchor" href="#how-the-sliding-window-counter-works" aria-hidden="true">#</a></h2>
<p>A <strong>sliding window counter</strong> gets most of the accuracy with two numbers: the count for the current and previous fixed windows, with the previous one weighted by how much of it still overlaps. <a href="https://blog.cloudflare.com/counting-things-a-lot-of-different-things/" target="_blank" rel="noopener noreferrer">Cloudflare described this approach for its own rate limiting in 2017</a>.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">slidingWindowCounter</span>(<span class="hljs-params">{ limit, windowMs }</span>) {
  <span class="hljs-keyword">const</span> state = <span class="hljs-keyword">new</span> <span class="hljs-title">Map</span>(); <span class="hljs-comment">// key -&gt; { windowStart, current, previous }</span>
  <span class="hljs-keyword">return</span> <span class="hljs-function">(<span class="hljs-params">key, now = <span class="hljs-built_in">Date</span>.now()</span>) =&gt;</span> {
    <span class="hljs-keyword">const</span> windowStart = now - (now % windowMs);
    <span class="hljs-keyword">let</span> s = state.<span class="hljs-title">get</span>(key);
    <span class="hljs-keyword">if</span> (!s) state.<span class="hljs-title">set</span>(key, (s = { windowStart, <span class="hljs-attr">current</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">previous</span>: <span class="hljs-number">0</span> }));
    <span class="hljs-keyword">if</span> (s.<span class="hljs-property">windowStart</span> !== windowStart) {
      <span class="hljs-comment">// one window later: current becomes previous; more than one: both reset</span>
      s.<span class="hljs-property">previous</span> = windowStart - s.<span class="hljs-property">windowStart</span> === windowMs ? s.<span class="hljs-property">current</span> : <span class="hljs-number">0</span>;
      s.<span class="hljs-property">current</span> = <span class="hljs-number">0</span>;
      s.<span class="hljs-property">windowStart</span> = windowStart;
    }
    <span class="hljs-keyword">const</span> elapsed = (now - windowStart) / windowMs; <span class="hljs-comment">// 0..1</span>
    <span class="hljs-keyword">const</span> estimate = s.<span class="hljs-property">previous</span> * (<span class="hljs-number">1</span> - elapsed) + s.<span class="hljs-property">current</span>;
    <span class="hljs-keyword">if</span> (estimate &gt;= limit) {
      <span class="hljs-keyword">return</span> { <span class="hljs-attr">allowed</span>: <span class="hljs-literal">false</span>, <span class="hljs-attr">retryAfterMs</span>: <span class="hljs-title">Math</span>.<span class="hljs-title">ceil</span>(windowMs * (<span class="hljs-number">1</span> - elapsed)) };
    }
    s.<span class="hljs-property">current</span>++;
    <span class="hljs-keyword">return</span> { <span class="hljs-attr">allowed</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">retryAfterMs</span>: <span class="hljs-number">0</span> };
  };
}
</code></pre></div>
<p>With the same 0:59.9 / 1:00.1 burst, this allows 101 requests instead of 200, because the previous window’s 100 requests still count at almost full weight.</p>
<h2 id="how-the-token-bucket-works">How the token bucket works <a class="post-heading-anchor" href="#how-the-token-bucket-works" aria-hidden="true">#</a></h2>
<p>Each key has a bucket holding up to <code>capacity</code> tokens, refilled continuously at <code>refillPerSec</code>. Each request takes one token; no token, no request.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">tokenBucket</span>(<span class="hljs-params">{ capacity, refillPerSec }</span>) {
  <span class="hljs-keyword">const</span> buckets = <span class="hljs-keyword">new</span> <span class="hljs-title">Map</span>(); <span class="hljs-comment">// key -&gt; { tokens, updatedAt }</span>
  <span class="hljs-keyword">return</span> <span class="hljs-function">(<span class="hljs-params">key, now = <span class="hljs-built_in">Date</span>.now()</span>) =&gt;</span> {
    <span class="hljs-keyword">let</span> b = buckets.<span class="hljs-title">get</span>(key);
    <span class="hljs-keyword">if</span> (!b) buckets.<span class="hljs-title">set</span>(key, (b = { <span class="hljs-attr">tokens</span>: capacity, <span class="hljs-attr">updatedAt</span>: now }));
    <span class="hljs-keyword">const</span> elapsedSec = (now - b.<span class="hljs-property">updatedAt</span>) / <span class="hljs-number">1000</span>;
    b.<span class="hljs-property">tokens</span> = <span class="hljs-title">Math</span>.<span class="hljs-title">min</span>(capacity, b.<span class="hljs-property">tokens</span> + elapsedSec * refillPerSec);
    b.<span class="hljs-property">updatedAt</span> = now;
    <span class="hljs-keyword">if</span> (b.<span class="hljs-property">tokens</span> &gt;= <span class="hljs-number">1</span>) {
      b.<span class="hljs-property">tokens</span> -= <span class="hljs-number">1</span>;
      <span class="hljs-keyword">return</span> { <span class="hljs-attr">allowed</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">retryAfterMs</span>: <span class="hljs-number">0</span> };
    }
    <span class="hljs-keyword">return</span> { <span class="hljs-attr">allowed</span>: <span class="hljs-literal">false</span>, <span class="hljs-attr">retryAfterMs</span>: <span class="hljs-title">Math</span>.<span class="hljs-title">ceil</span>(((<span class="hljs-number">1</span> - b.<span class="hljs-property">tokens</span>) / refillPerSec) * <span class="hljs-number">1000</span>) };
  };
}
</code></pre></div>
<p>The two parameters separate what the window algorithms blur together: the burst you tolerate (<code>capacity</code>) and the sustained rate (<code>refillPerSec</code>). A bucket of 20 refilling at 5 per second lets a client page through a list quickly, then holds it to 5 per second. <code>Retry-After</code> is exact: the time until one token is available. <a href="https://stripe.com/blog/rate-limiters" target="_blank" rel="noopener noreferrer">Stripe described using token buckets for its API in 2017</a>.</p>
<h2 id="fixed-window-vs-sliding-window-vs-token-bucket">Fixed window vs sliding window vs token bucket <a class="post-heading-anchor" href="#fixed-window-vs-sliding-window-vs-token-bucket" aria-hidden="true">#</a></h2>
<div class="post-table"><table>
<thead>
<tr>
<th></th>
<th>Fixed window</th>
<th>Sliding window counter</th>
<th>Token bucket</th>
</tr>
</thead>
<tbody>
<tr>
<td>State per key</td>
<td>1 counter</td>
<td>2 counters + timestamp</td>
<td>tokens + timestamp</td>
</tr>
<tr>
<td>Boundary burst</td>
<td>Up to 2x the limit</td>
<td>About 1x</td>
<td>Up to <code>capacity</code></td>
</tr>
<tr>
<td>Burst control</td>
<td>None</td>
<td>None</td>
<td>Explicit</td>
</tr>
<tr>
<td>Retry-After accuracy</td>
<td>Window end</td>
<td>Approximate</td>
<td>Exact</td>
</tr>
<tr>
<td>Good for</td>
<td>Daily or monthly quotas</td>
<td>Simple per-client limits</td>
<td>APIs with bursty, legitimate clients</td>
</tr>
</tbody>
</table></div>
<h2 id="node-js-rate-limiting-across-several-instances-with-redis">Node.js rate limiting across several instances with Redis <a class="post-heading-anchor" href="#node-js-rate-limiting-across-several-instances-with-redis" aria-hidden="true">#</a></h2>
<p>In-memory limiters have one serious flaw: with four Node instances, each enforces its own budget, so a client gets four times the limit. Move the state to Redis and do the read-modify-write in one Lua script so concurrent requests cannot race. The script uses Redis’s clock (<code>TIME</code>), so instances with drifting clocks agree.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> <span class="hljs-title">Redis</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'ioredis'</span>;

<span class="hljs-keyword">const</span> redis = <span class="hljs-keyword">new</span> <span class="hljs-title">Redis</span>(process.<span class="hljs-property">env</span>.<span class="hljs-property">REDIS_URL</span>);

redis.<span class="hljs-title">defineCommand</span>(<span class="hljs-string">'tokenBucket'</span>, {
  <span class="hljs-attr">numberOfKeys</span>: <span class="hljs-number">1</span>,
  <span class="hljs-attr">lua</span>: <span class="hljs-string">`
    local capacity = tonumber(ARGV[1])
    local perMs    = tonumber(ARGV[2])
    local t        = redis.call('TIME')
    local now      = t[1] * 1000 + math.floor(t[2] / 1000)
    local b        = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
    local tokens   = tonumber(b[1]) or capacity
    local ts       = tonumber(b[2]) or now
    tokens = math.min(capacity, tokens + (now - ts) * perMs)
    local allowed = 0
    if tokens &gt;= 1 then
      tokens = tokens - 1
      allowed = 1
    end
    redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
    redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / perMs))
    return { allowed, math.ceil((1 - tokens) / perMs) }
  `</span>,
});

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">rateLimit</span>(<span class="hljs-params">{ capacity, perSecond, keyFor }</span>) {
  <span class="hljs-keyword">return</span> <span class="hljs-title">async</span> (req, res, next) =&gt; {
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> [allowed, waitMs] = <span class="hljs-keyword">await</span> redis.<span class="hljs-title">tokenBucket</span>(
        <span class="hljs-string">`rl:<span class="hljs-subst">${keyFor(req)}</span>`</span>, capacity, perSecond / <span class="hljs-number">1000</span>
      );
      <span class="hljs-keyword">if</span> (allowed === <span class="hljs-number">1</span>) <span class="hljs-keyword">return</span> <span class="hljs-title">next</span>();
      res.<span class="hljs-title">set</span>(<span class="hljs-string">'Retry-After'</span>, <span class="hljs-title">String</span>(<span class="hljs-title">Math</span>.<span class="hljs-title">max</span>(<span class="hljs-number">1</span>, <span class="hljs-title">Math</span>.<span class="hljs-title">ceil</span>(waitMs / <span class="hljs-number">1000</span>))));
      <span class="hljs-keyword">return</span> res.<span class="hljs-title">status</span>(<span class="hljs-number">429</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">error</span>: <span class="hljs-string">'rate_limited'</span> });
    } <span class="hljs-keyword">catch</span> (err) {
      req.<span class="hljs-property">log</span>?.<span class="hljs-title">warn</span>({ err }, <span class="hljs-string">'rate limiter unavailable, allowing request'</span>);
      <span class="hljs-keyword">return</span> <span class="hljs-title">next</span>(); <span class="hljs-comment">// fail open</span>
    }
  };
}
</code></pre></div>
<p>Idle keys expire once a full bucket would have refilled. Failing open keeps a Redis outage from becoming an API outage; for a login endpoint you might fail closed instead. Log every <code>429</code> with the key that hit it (<a href="/blog/pino-logging-nodejs">structured logging with Pino</a> makes that a one-line query), and keep your <a href="/blog/nodejs-health-check">health check endpoints</a> out of the limiter so your platform does not mark instances unhealthy.</p>
<h2 id="or-use-express-rate-limit-or-fastify-rate-limit">Or use express-rate-limit or @fastify/rate-limit <a class="post-heading-anchor" href="#or-use-express-rate-limit-or-fastify-rate-limit" aria-hidden="true">#</a></h2>
<p>You do not have to write this yourself. <code>express-rate-limit</code> (v8) uses a fixed window and supports external stores; set <code>limit</code>, <code>windowMs</code>, <code>standardHeaders: 'draft-8'</code> and <code>ipv6Subnet</code>, which groups IPv6 addresses by prefix. <code>@fastify/rate-limit</code> takes <code>max</code> and <code>timeWindow</code> and sends <code>429</code> with <code>retry-after</code>. <code>rate-limiter-flexible</code> offers several algorithms for both.</p>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<ul>
<li><strong>Solo developer, one instance:</strong> add <code>express-rate-limit</code> or <code>@fastify/rate-limit</code> with the in-memory store, a strict limit on login and signup, and a generous one everywhere else. An afternoon’s work.</li>
<li><strong>Team of five to twenty, several instances:</strong> use a Redis store, key authenticated traffic on API key or account, and agree on the numbers before an incident forces them.</li>
<li><strong>Everyone:</strong> confirm <code>trust proxy</code> is right by logging <code>req.ip</code> once in production.</li>
</ul>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="what-is-the-best-rate-limiting-algorithm-for-an-api">What is the best rate limiting algorithm for an API? <a class="post-heading-anchor" href="#what-is-the-best-rate-limiting-algorithm-for-an-api" aria-hidden="true">#</a></h3>
<p>For per-client limits on an API, the token bucket is usually the best fit because it allows short bursts and still enforces a steady rate. A sliding window counter is a close second when you want simple “N per minute” semantics.</p>
<h3 id="does-express-rate-limit-work-with-multiple-servers">Does express-rate-limit work with multiple servers? <a class="post-heading-anchor" href="#does-express-rate-limit-work-with-multiple-servers" aria-hidden="true">#</a></h3>
<p>Only with an external store. The default memory store keeps counts per process, so each instance enforces its own limit. Use a shared store such as Redis.</p>
<h3 id="should-rate-limiting-return-429-or-503">Should rate limiting return 429 or 503? <a class="post-heading-anchor" href="#should-rate-limiting-return-429-or-503" aria-hidden="true">#</a></h3>
<p>Return <code>429 Too Many Requests</code> when a specific client exceeded its allowance, with <code>Retry-After</code>. <code>503</code> means the whole service is unavailable, which tells clients the wrong thing.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not build and run a limiter yourself, <a href="https://www.apitraffic.io/waf">ApiTraffic’s firewall</a> includes <a href="https://www.apitraffic.io/docs/waf/policies">rate limits</a> per IP, per header (such as an API key) or per path. Callers over the limit get <code>429</code> with <code>Retry-After</code>, from the same SDK that logs your requests.</p>
<ul>
<li>Policies are set in the dashboard and pushed to your servers without a deploy, so the 3am fix is a setting, not a release.</li>
<li><a href="https://www.apitraffic.io/docs/waf/overview">Monitor mode</a> shows what a new limit would block before it blocks anything.</li>
<li>The <a href="https://www.apitraffic.io/docs/traffic/request-log">request log</a> shows who hit the limit: <code>status_code == 429 AND url_path == "/v1/search"</code>.</li>
</ul>
<p>Start free and put a rate limit in front of your API without a deploy: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260202-nodejs-rate-limiting">apitraffic.io</a></p>
<p>Sources: <a href="https://www.rfc-editor.org/rfc/rfc6585" target="_blank" rel="noopener noreferrer">RFC 6585, 429 Too Many Requests</a>; <a href="https://www.rfc-editor.org/rfc/rfc9110#field.retry-after" target="_blank" rel="noopener noreferrer">RFC 9110, Retry-After</a>; <a href="https://blog.cloudflare.com/counting-things-a-lot-of-different-things/" target="_blank" rel="noopener noreferrer">Cloudflare: How we built rate limiting capable of scaling to millions of domains (2017)</a>; <a href="https://stripe.com/blog/rate-limiters" target="_blank" rel="noopener noreferrer">Stripe: Scaling your API with rate limiters (2017)</a>; <a href="https://www.npmjs.com/package/express-rate-limit" target="_blank" rel="noopener noreferrer">express-rate-limit README</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Node.js health checks that tell the truth: liveness vs readiness</title>
      <link>https://www.apitraffic.io/blog/nodejs-health-check</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/nodejs-health-check</guid>
      <pubDate>Tue, 13 Jan 2026 14:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>A database blip should not restart every replica of your API. Split /health into liveness and readiness, with Express and Fastify code you can ship today.</description>
      <content:encoded><![CDATA[<p>The database had a 40-second blip, and every replica of your API restarted at once, so the outage lasted ten minutes instead of 40 seconds. That is what happens when a Node.js health check pings the database from the wrong probe.</p>
<p>The short answer: use two endpoints. A liveness check (<code>/livez</code>) answers “is this process stuck?” and checks nothing external. A readiness check (<code>/readyz</code>) answers “should this instance get traffic right now?” and checks critical dependencies with a short timeout. Restarts follow liveness, traffic routing follows readiness. Below are working <a href="https://www.apitraffic.io/express">Express</a> and <a href="https://www.apitraffic.io/fastify">Fastify</a> versions and the mistakes that make health checks lie.</p>
<h2 id="liveness-vs-readiness-what-is-the-difference">Liveness vs readiness: what is the difference? <a class="post-heading-anchor" href="#liveness-vs-readiness-what-is-the-difference" aria-hidden="true">#</a></h2>
<div class="post-table"><table>
<thead>
<tr>
<th></th>
<th>Liveness</th>
<th>Readiness</th>
</tr>
</thead>
<tbody>
<tr>
<td>Question</td>
<td>Is the process stuck?</td>
<td>Can it serve requests now?</td>
</tr>
<tr>
<td>On failure (Kubernetes)</td>
<td>Container is restarted</td>
<td>Pod is removed from Service endpoints</td>
</tr>
<tr>
<td>Checks dependencies?</td>
<td>No</td>
<td>Critical ones, with a timeout</td>
</tr>
<tr>
<td>Should be</td>
<td>Cheap, local, tolerant</td>
<td>Accurate, fast to react</td>
</tr>
</tbody>
</table></div>
<p>Kubernetes also has a startup probe, which holds off the other two until a slow-booting app is ready. Docker’s <code>HEALTHCHECK</code>, load balancer target checks and the health check path on platforms such as Render, Railway and <a href="http://Fly.io" target="_blank" rel="noopener noreferrer">Fly.io</a> implement versions of the same idea, often with only one check, which makes picking the right semantics more important.</p>
<p>The rule: liveness checks the process, readiness checks its ability to do useful work. Put a database ping in liveness and a short database incident becomes a crash loop across every replica.</p>
<h2 id="what-should-a-node-js-health-check-actually-test">What should a Node.js health check actually test? <a class="post-heading-anchor" href="#what-should-a-node-js-health-check-actually-test" aria-hidden="true">#</a></h2>
<p>Liveness should test:</p>
<ul>
<li>The event loop is responsive. If the handler runs at all, it mostly is.</li>
<li>No known-fatal state you can detect yourself.</li>
</ul>
<p>Readiness should test:</p>
<ul>
<li>Startup work is finished: config loaded, migrations checked.</li>
<li>Critical dependencies answer within a short timeout. Usually that is the primary database and little else.</li>
<li>The process is not shutting down.</li>
<li>Optionally, event loop delay is under a threshold.</li>
</ul>
<p>Leave out dependencies only some endpoints need. If your email provider is down, reads still work, so stay in rotation and fail the send-email path on its own.</p>
<h2 id="how-to-add-a-health-check-endpoint-in-express">How to add a health check endpoint in Express <a class="post-heading-anchor" href="#how-to-add-a-health-check-endpoint-in-express" aria-hidden="true">#</a></h2>
<p>This uses Express 5 and <code>pg</code>. The parts that matter are the timeout, the cached result so probes do not hammer the database, and the <code>shuttingDown</code> flag.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> pg <span class="hljs-keyword">from</span> <span class="hljs-string">'pg'</span>;
<span class="hljs-keyword">import</span> { monitorEventLoopDelay } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:perf_hooks'</span>;

<span class="hljs-keyword">const</span> app = <span class="hljs-title">express</span>();
<span class="hljs-keyword">const</span> pool = <span class="hljs-keyword">new</span> pg.<span class="hljs-title">Pool</span>({ <span class="hljs-attr">connectionString</span>: process.<span class="hljs-property">env</span>.<span class="hljs-property">DATABASE_URL</span> });
<span class="hljs-keyword">const</span> loopDelay = <span class="hljs-title">monitorEventLoopDelay</span>({ <span class="hljs-attr">resolution</span>: <span class="hljs-number">20</span> });
loopDelay.<span class="hljs-title">enable</span>();

<span class="hljs-keyword">let</span> started = <span class="hljs-literal">false</span>;
<span class="hljs-keyword">let</span> shuttingDown = <span class="hljs-literal">false</span>;
<span class="hljs-keyword">let</span> lastCheck = { <span class="hljs-attr">ok</span>: <span class="hljs-literal">false</span>, <span class="hljs-attr">at</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">detail</span>: {} };

<span class="hljs-keyword">const</span> <span class="hljs-title">withTimeout</span> = (<span class="hljs-params">promise, ms</span>) =&gt;
  <span class="hljs-title">Promise</span>.<span class="hljs-title">race</span>([
    promise,
    <span class="hljs-keyword">new</span> <span class="hljs-title">Promise</span>(<span class="hljs-function">(<span class="hljs-params">_, reject</span>) =&gt;</span>
      <span class="hljs-built_in">setTimeout</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-title">reject</span>(<span class="hljs-keyword">new</span> <span class="hljs-title">Error</span>(<span class="hljs-string">`timeout after <span class="hljs-subst">${ms}</span>ms`</span>)), ms)),
  ]);

<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title">checkDependencies</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">const</span> detail = {};
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">await</span> <span class="hljs-title">withTimeout</span>(pool.<span class="hljs-title">query</span>(<span class="hljs-string">'SELECT 1'</span>), <span class="hljs-number">500</span>);
    detail.<span class="hljs-property">db</span> = <span class="hljs-string">'ok'</span>;
  } <span class="hljs-keyword">catch</span> (err) {
    detail.<span class="hljs-property">db</span> = err.<span class="hljs-property">message</span>;
  }
  <span class="hljs-keyword">const</span> p99ms = loopDelay.<span class="hljs-title">percentile</span>(<span class="hljs-number">99</span>) / <span class="hljs-number">1e6</span>;
  detail.<span class="hljs-property">eventLoopP99Ms</span> = <span class="hljs-title">Math</span>.<span class="hljs-title">round</span>(p99ms);
  loopDelay.<span class="hljs-title">reset</span>();
  lastCheck = { <span class="hljs-attr">ok</span>: detail.<span class="hljs-property">db</span> === <span class="hljs-string">'ok'</span> &amp;&amp; p99ms &lt; <span class="hljs-number">500</span>, <span class="hljs-attr">at</span>: <span class="hljs-title">Date</span>.<span class="hljs-title">now</span>(), detail };
  <span class="hljs-keyword">return</span> lastCheck;
}

app.<span class="hljs-title">get</span>(<span class="hljs-string">'/livez'</span>, <span class="hljs-function">(<span class="hljs-params">req, res</span>) =&gt;</span> res.<span class="hljs-title">json</span>({ <span class="hljs-attr">status</span>: <span class="hljs-string">'alive'</span> }));

app.<span class="hljs-title">get</span>(<span class="hljs-string">'/readyz'</span>, <span class="hljs-title">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">if</span> (!started || shuttingDown) {
    <span class="hljs-keyword">return</span> res.<span class="hljs-title">status</span>(<span class="hljs-number">503</span>).<span class="hljs-title">json</span>({ <span class="hljs-attr">status</span>: <span class="hljs-string">'not ready'</span>, started, shuttingDown });
  }
  <span class="hljs-keyword">const</span> fresh = <span class="hljs-title">Date</span>.<span class="hljs-title">now</span>() - lastCheck.<span class="hljs-property">at</span> &lt; <span class="hljs-number">2000</span>;
  <span class="hljs-keyword">const</span> result = fresh ? lastCheck : <span class="hljs-keyword">await</span> <span class="hljs-title">checkDependencies</span>();
  res.<span class="hljs-title">status</span>(result.<span class="hljs-property">ok</span> ? <span class="hljs-number">200</span> : <span class="hljs-number">503</span>).<span class="hljs-title">json</span>(result.<span class="hljs-property">detail</span>);
});

<span class="hljs-keyword">const</span> server = app.<span class="hljs-title">listen</span>(<span class="hljs-number">3000</span>, <span class="hljs-title">async</span> () =&gt; {
  <span class="hljs-keyword">await</span> <span class="hljs-title">checkDependencies</span>();
  started = <span class="hljs-literal">true</span>;
});

process.<span class="hljs-title">on</span>(<span class="hljs-string">'SIGTERM'</span>, <span class="hljs-function">() =&gt;</span> {
  shuttingDown = <span class="hljs-literal">true</span>;
  <span class="hljs-built_in">setTimeout</span>(<span class="hljs-function">() =&gt;</span> server.<span class="hljs-title">close</span>(<span class="hljs-function">() =&gt;</span> pool.<span class="hljs-title">end</span>()), <span class="hljs-number">5000</span>);
});
</code></pre></div>
<p><code>withTimeout</code> stops the probe from waiting but does not cancel the query, which is one more reason to cache the result for a couple of seconds.</p>
<p>The <code>SIGTERM</code> handler flips readiness first and closes the server a few seconds later, so the platform stops routing before connections close. The rest of that sequence is covered in <a href="/blog/nodejs-graceful-shutdown">graceful shutdown in Node.js</a>.</p>
<h2 id="how-to-add-health-checks-in-fastify-with-under-pressure">How to add health checks in Fastify with under-pressure <a class="post-heading-anchor" href="#how-to-add-health-checks-in-fastify-with-under-pressure" aria-hidden="true">#</a></h2>
<p>Fastify’s official <a href="https://github.com/fastify/under-pressure" target="_blank" rel="noopener noreferrer"><code>@fastify/under-pressure</code> plugin</a> tracks event loop delay, heap, RSS and event loop utilization against thresholds you set, answers with Service Unavailable when they are exceeded, and can run a <code>healthCheck</code> function on an interval behind a status route.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> <span class="hljs-title">Fastify</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'fastify'</span>;
<span class="hljs-keyword">import</span> underPressure <span class="hljs-keyword">from</span> <span class="hljs-string">'@fastify/under-pressure'</span>;
<span class="hljs-keyword">import</span> pg <span class="hljs-keyword">from</span> <span class="hljs-string">'pg'</span>;

<span class="hljs-keyword">const</span> app = <span class="hljs-title">Fastify</span>({ <span class="hljs-attr">logger</span>: <span class="hljs-literal">true</span> });
<span class="hljs-keyword">const</span> pool = <span class="hljs-keyword">new</span> pg.<span class="hljs-title">Pool</span>({ <span class="hljs-attr">connectionString</span>: process.<span class="hljs-property">env</span>.<span class="hljs-property">DATABASE_URL</span> });

<span class="hljs-keyword">await</span> app.<span class="hljs-title">register</span>(underPressure, {
  <span class="hljs-attr">maxEventLoopDelay</span>: <span class="hljs-number">1000</span>,
  <span class="hljs-attr">maxEventLoopUtilization</span>: <span class="hljs-number">0.98</span>,
  <span class="hljs-attr">retryAfter</span>: <span class="hljs-number">50</span>,
  <span class="hljs-attr">healthCheck</span>: <span class="hljs-title">async</span> () =&gt; {
    <span class="hljs-keyword">await</span> pool.<span class="hljs-title">query</span>(<span class="hljs-string">'SELECT 1'</span>);
    <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;
  },
  <span class="hljs-attr">healthCheckInterval</span>: <span class="hljs-number">5000</span>,
  <span class="hljs-attr">exposeStatusRoute</span>: <span class="hljs-string">'/readyz'</span>,
});

app.<span class="hljs-title">get</span>(<span class="hljs-string">'/livez'</span>, <span class="hljs-title">async</span> () =&gt; ({ <span class="hljs-attr">status</span>: <span class="hljs-string">'alive'</span> }));
<span class="hljs-keyword">await</span> app.<span class="hljs-title">listen</span>({ <span class="hljs-attr">port</span>: <span class="hljs-number">3000</span>, <span class="hljs-attr">host</span>: <span class="hljs-string">'0.0.0.0'</span> });
</code></pre></div>
<p>Once a threshold is crossed, under-pressure rejects ordinary requests too. That is load shedding, usually what you want, but test it before an incident does.</p>
<h2 id="configuring-liveness-and-readiness-probes-in-kubernetes">Configuring liveness and readiness probes in Kubernetes <a class="post-heading-anchor" href="#configuring-liveness-and-readiness-probes-in-kubernetes" aria-hidden="true">#</a></h2>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">yaml</span></div><pre><code class="language-yaml hljs"><span class="hljs-attr">startupProbe:</span>
  <span class="hljs-attr">httpGet:</span> { <span class="hljs-attr">path:</span> <span class="hljs-string">/readyz</span>, <span class="hljs-attr">port:</span> <span class="hljs-number">3000</span> }
  <span class="hljs-attr">periodSeconds:</span> <span class="hljs-number">2</span>
  <span class="hljs-attr">failureThreshold:</span> <span class="hljs-number">30</span>
<span class="hljs-attr">livenessProbe:</span>
  <span class="hljs-attr">httpGet:</span> { <span class="hljs-attr">path:</span> <span class="hljs-string">/livez</span>, <span class="hljs-attr">port:</span> <span class="hljs-number">3000</span> }
  <span class="hljs-attr">periodSeconds:</span> <span class="hljs-number">10</span>
  <span class="hljs-attr">timeoutSeconds:</span> <span class="hljs-number">2</span>
  <span class="hljs-attr">failureThreshold:</span> <span class="hljs-number">3</span>
<span class="hljs-attr">readinessProbe:</span>
  <span class="hljs-attr">httpGet:</span> { <span class="hljs-attr">path:</span> <span class="hljs-string">/readyz</span>, <span class="hljs-attr">port:</span> <span class="hljs-number">3000</span> }
  <span class="hljs-attr">periodSeconds:</span> <span class="hljs-number">5</span>
  <span class="hljs-attr">timeoutSeconds:</span> <span class="hljs-number">2</span>
  <span class="hljs-attr">failureThreshold:</span> <span class="hljs-number">2</span>
</code></pre></div>
<p>Liveness is deliberately tolerant. Readiness reacts faster because leaving rotation is cheap and reversible.</p>
<p>Team size changes the setup. A one-person team on a PaaS or a single VM usually gets one health check setting: point it at <code>/readyz</code> if the platform only uses it to gate deploys and routing, and at <code>/livez</code> if failures trigger restarts. A ten-person team on Kubernetes or ECS should wire up both probes as above and review the thresholds after each incident.</p>
<h2 id="why-health-checks-lie">Why health checks lie <a class="post-heading-anchor" href="#why-health-checks-lie" aria-hidden="true">#</a></h2>
<ul>
<li>They always return 200, so nothing is checked.</li>
<li>They check every dependency, so a partial outage becomes a full one.</li>
<li>They have no timeout, so the probe dies and you lose the reason.</li>
<li>They are public. Dependency names and latencies are free reconnaissance, so block <code>/readyz</code> detail at your edge.</li>
<li>They are expensive. Several load balancers probing every few seconds adds up.</li>
<li>They drown your logs. On a quiet service, probes can be most of your traffic, so make them easy to filter out.</li>
</ul>
<h2 id="health-check-checklist">Health check checklist <a class="post-heading-anchor" href="#health-check-checklist" aria-hidden="true">#</a></h2>
<p>If you only do one thing, split <code>/health</code> into <code>/livez</code> and <code>/readyz</code> and move the database ping to readiness. Then:</p>
<ul>
<li>Readiness checks startup, shutdown, critical dependencies with a timeout, and optionally event loop delay.</li>
<li>Readiness returns 503 before the server closes on <code>SIGTERM</code>.</li>
<li>Dependency results are cached for a few seconds.</li>
<li>Health routes do not expose detail to the internet.</li>
</ul>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="should-a-health-check-endpoint-check-the-database">Should a health check endpoint check the database? <a class="post-heading-anchor" href="#should-a-health-check-endpoint-check-the-database" aria-hidden="true">#</a></h3>
<p>The readiness check should, with a timeout of a few hundred milliseconds. The liveness check should not, because restarting your API does not fix the database and causes restart storms.</p>
<h3 id="what-status-code-should-a-failing-health-check-return">What status code should a failing health check return? <a class="post-heading-anchor" href="#what-status-code-should-a-failing-health-check-return" aria-hidden="true">#</a></h3>
<p>Return <code>503 Service Unavailable</code>. <a href="https://kubernetes.io/docs/concepts/workloads/pods/probes/" target="_blank" rel="noopener noreferrer">Kubernetes HTTP probes treat any status from 200 to 399 as success</a>, and most load balancers expect 200 for healthy.</p>
<h3 id="how-often-should-health-checks-run">How often should health checks run? <a class="post-heading-anchor" href="#how-often-should-health-checks-run" aria-hidden="true">#</a></h3>
<p>Every 5 to 10 seconds is typical. Cache the expensive part so the check costs one cheap query, even with several callers.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>Health checks describe one instance at one moment. If you would rather not build dashboards to see what users experienced meanwhile, ApiTraffic records it for you.</p>
<ul>
<li>Requests are recorded under the <a href="https://www.apitraffic.io/docs/endpoint-handling">matched route pattern</a>, so <code>/readyz</code> gets its own request count and p50, p95 and p99 over time. A readiness p99 creeping toward the probe timeout is an early warning; see <a href="/blog/api-latency-percentiles">how to read latency percentiles</a>.</li>
<li><a href="https://www.apitraffic.io/docs/traffic/search">Search for flapping</a> with <code>url_path == "/readyz" AND status_code == 503</code> to line up when an instance left rotation with what else failed.</li>
<li>With the <a href="https://www.apitraffic.io/docs/apm/setup">APM preload</a> (<code>node -r @apitraffic/node/register ./server.js</code>), the database span inside each readiness check shows on the request waterfall.</li>
</ul>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260113-nodejs-health-check">apitraffic.io</a></p>
<p>Sources: <a href="https://kubernetes.io/docs/concepts/workloads/pods/probes/" target="_blank" rel="noopener noreferrer">Kubernetes, “Liveness, Readiness, and Startup Probes”</a>; <a href="https://github.com/fastify/under-pressure" target="_blank" rel="noopener noreferrer">@fastify/under-pressure README</a>; <a href="https://nodejs.org/api/perf_hooks.html" target="_blank" rel="noopener noreferrer">Node.js perf_hooks, monitorEventLoopDelay</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>How to redact sensitive data in logs for Node.js APIs</title>
      <link>https://www.apitraffic.io/blog/redact-sensitive-data-logs</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/redact-sensitive-data-logs</guid>
      <pubDate>Mon, 05 Jan 2026 14:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>Found a bearer token in your logs? Here&apos;s how to keep secrets and personal data out of Node API logs in an afternoon: Pino redaction, URL and error scrubbing, body allowlists and a test that catches leaks.</description>
      <content:encoded><![CDATA[<p>Someone on the team searches the logs for a bug and finds a customer’s bearer token sitting in plain text. Now the log provider, every engineer with log access and every backup holds a working credential. If you need to redact sensitive data in logs, the short answer is: decide what never gets logged, enforce it in the logger itself (not at each call site), and add a test that fails when a secret slips through.</p>
<p>For most Node APIs that means three changes: Pino’s <code>redact</code> option for known paths, a scrubber for URLs and error objects, and an allowlist for request bodies. A 1-person team can do all three in an afternoon. If you’re starting from <code>console.log</code>, set up <a href="/blog/pino-logging-nodejs">structured logging with Pino</a> first; redaction depends on logs being objects, not strings.</p>
<h2 id="what-not-to-log-the-short-list">What not to log: the short list <a class="post-heading-anchor" href="#what-not-to-log-the-short-list" aria-hidden="true">#</a></h2>
<p>The <a href="https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html" target="_blank" rel="noopener noreferrer">OWASP Logging Cheat Sheet</a>’s list of data to exclude or mask is the best starting point. For a typical API:</p>
<div class="post-table"><table>
<thead>
<tr>
<th>Never log</th>
<th>Where it hides</th>
</tr>
</thead>
<tbody>
<tr>
<td>Passwords and password reset tokens</td>
<td>Login and reset bodies, reset links in query strings</td>
</tr>
<tr>
<td>Bearer tokens, session cookies, API keys</td>
<td><code>Authorization</code>, <code>Cookie</code>, <code>X-Api-Key</code>, <code>Set-Cookie</code> headers</td>
</tr>
<tr>
<td>Connection strings and signing secrets</td>
<td>Error objects, startup config dumps</td>
</tr>
<tr>
<td>Payment card data (card number, CVV)</td>
<td>Checkout request bodies</td>
</tr>
<tr>
<td>Personal data you don’t need</td>
<td>Emails, phone numbers, addresses, dates of birth in bodies</td>
</tr>
</tbody>
</table></div>
<p>Card data is the strict one: PCI DSS doesn’t allow storing card verification codes after authorization at all, and logs count as storage. Personal data is a judgment call, but “we might need it to debug” rarely survives a data protection question. Log a stable user ID instead of an email.</p>
<h2 id="how-to-redact-sensitive-data-in-logs-with-pino">How to redact sensitive data in logs with Pino <a class="post-heading-anchor" href="#how-to-redact-sensitive-data-in-logs-with-pino" aria-hidden="true">#</a></h2>
<p><a href="https://getpino.io/#/docs/redaction" target="_blank" rel="noopener noreferrer">Pino’s <code>redact</code> option</a> takes a list of paths and replaces their values before the line is written. It handles nested keys, wildcards and bracket notation for header names with hyphens:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// logger.js</span>
<span class="hljs-keyword">import</span> pino <span class="hljs-keyword">from</span> <span class="hljs-string">'pino'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> logger = <span class="hljs-title">pino</span>({
  <span class="hljs-attr">redact</span>: {
    <span class="hljs-attr">paths</span>: [
      <span class="hljs-string">'req.headers.authorization'</span>,
      <span class="hljs-string">'req.headers.cookie'</span>,
      <span class="hljs-string">'req.headers["x-api-key"]'</span>,
      <span class="hljs-string">'res.headers["set-cookie"]'</span>,
      <span class="hljs-string">'*.password'</span>,
      <span class="hljs-string">'*.token'</span>,
      <span class="hljs-string">'*.refreshToken'</span>,
      <span class="hljs-string">'body.card.number'</span>,
      <span class="hljs-string">'body.card.cvc'</span>,
    ],
    <span class="hljs-attr">censor</span>: <span class="hljs-string">'[REDACTED]'</span>,
  },
});
</code></pre></div>
<p>Use <code>remove: true</code> instead of <code>censor</code> if you’d rather drop the key entirely. Note that <code>*.password</code> matches one level deep; <code>user.credentials.password</code> needs its own path. Redaction paths are a denylist, so they only catch what you thought of. That’s why the next two steps exist.</p>
<p>With <code>pino-http</code> in <a href="https://www.apitraffic.io/express">Express</a>, the same logger covers request and response logs:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> pinoHttp <span class="hljs-keyword">from</span> <span class="hljs-string">'pino-http'</span>;
<span class="hljs-keyword">import</span> { logger } <span class="hljs-keyword">from</span> <span class="hljs-string">'./logger.js'</span>;

<span class="hljs-keyword">const</span> app = <span class="hljs-title">express</span>();
app.<span class="hljs-title">use</span>(<span class="hljs-title">pinoHttp</span>({ logger }));
</code></pre></div>
<h2 id="scrub-tokens-from-urls-and-query-strings">Scrub tokens from URLs and query strings <a class="post-heading-anchor" href="#scrub-tokens-from-urls-and-query-strings" aria-hidden="true">#</a></h2>
<p>Tokens in URLs get past header redaction: <code>/reset-password?token=...</code>, <code>/oauth/callback?code=...</code>, <code>/download?signature=...</code>. Log the path with sensitive parameters masked:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">const</span> <span class="hljs-variable">SENSITIVE_PARAMS</span> = <span class="hljs-keyword">new</span> <span class="hljs-title">Set</span>([<span class="hljs-string">'token'</span>, <span class="hljs-string">'code'</span>, <span class="hljs-string">'signature'</span>, <span class="hljs-string">'api_key'</span>, <span class="hljs-string">'access_token'</span>, <span class="hljs-string">'password'</span>]);

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">safeUrl</span>(<span class="hljs-params">raw</span>) {
  <span class="hljs-keyword">const</span> url = <span class="hljs-keyword">new</span> <span class="hljs-title">URL</span>(raw, <span class="hljs-string">'http://localhost'</span>);
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> key <span class="hljs-keyword">of</span> [...url.<span class="hljs-property">searchParams</span>.<span class="hljs-title">keys</span>()]) {
    <span class="hljs-keyword">if</span> (<span class="hljs-variable">SENSITIVE_PARAMS</span>.<span class="hljs-title">has</span>(key.<span class="hljs-title">toLowerCase</span>())) url.<span class="hljs-property">searchParams</span>.<span class="hljs-title">set</span>(key, <span class="hljs-string">'[REDACTED]'</span>);
  }
  <span class="hljs-keyword">return</span> url.<span class="hljs-property">pathname</span> + url.<span class="hljs-property">search</span>;
}
</code></pre></div>
<p>Pass it to <code>pino-http</code> through a custom serializer:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">app.<span class="hljs-title">use</span>(<span class="hljs-title">pinoHttp</span>({
  logger,
  <span class="hljs-attr">serializers</span>: {
    <span class="hljs-title">req</span>(<span class="hljs-params">req</span>) {
      <span class="hljs-keyword">return</span> { <span class="hljs-attr">method</span>: req.<span class="hljs-property">method</span>, <span class="hljs-attr">url</span>: <span class="hljs-title">safeUrl</span>(req.<span class="hljs-property">url</span>), <span class="hljs-attr">id</span>: req.<span class="hljs-property">id</span> };
    },
  },
}));
</code></pre></div>
<p>This serializer also stops logging request headers entirely, which is a reasonable default. Add back the specific headers you need, such as <code>user-agent</code>.</p>
<h2 id="why-error-objects-leak-secrets">Why error objects leak secrets <a class="post-heading-anchor" href="#why-error-objects-leak-secrets" aria-hidden="true">#</a></h2>
<p>Errors are the most common leak because they carry context you didn’t choose. Axios errors include a <code>config</code> object with the request headers, so <code>logger.error(err)</code> after a failed call to a payment provider can log your provider’s API key. Database driver errors can include the query and its parameters. Serialize errors yourself:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">safeError</span>(<span class="hljs-params">err</span>) {
  <span class="hljs-keyword">return</span> {
    <span class="hljs-attr">name</span>: err.<span class="hljs-property">name</span>,
    <span class="hljs-attr">message</span>: err.<span class="hljs-property">message</span>,
    <span class="hljs-attr">code</span>: err.<span class="hljs-property">code</span>,
    <span class="hljs-attr">status</span>: err.<span class="hljs-property">response</span>?.<span class="hljs-property">status</span> ?? err.<span class="hljs-property">status</span>,
    <span class="hljs-attr">stack</span>: err.<span class="hljs-property">stack</span>,
  };
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> logger = <span class="hljs-title">pino</span>({
  <span class="hljs-attr">serializers</span>: { <span class="hljs-attr">err</span>: safeError },
  <span class="hljs-attr">redact</span>: { <span class="hljs-attr">paths</span>: [<span class="hljs-comment">/* as above */</span>], <span class="hljs-attr">censor</span>: <span class="hljs-string">'[REDACTED]'</span> },
});
</code></pre></div>
<p>Now <code>logger.error({ err }, 'payment call failed')</code> logs what you need to debug and nothing from the request config.</p>
<h2 id="log-request-bodies-with-an-allowlist-not-a-denylist">Log request bodies with an allowlist, not a denylist <a class="post-heading-anchor" href="#log-request-bodies-with-an-allowlist-not-a-denylist" aria-hidden="true">#</a></h2>
<p>For bodies, flip the default: log nothing unless a route says which fields are safe.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">const</span> <span class="hljs-variable">BODY_FIELDS</span> = {
  <span class="hljs-string">'POST /orders'</span>: [<span class="hljs-string">'items'</span>, <span class="hljs-string">'currency'</span>, <span class="hljs-string">'couponCode'</span>],
  <span class="hljs-string">'POST /signup'</span>: [<span class="hljs-string">'plan'</span>, <span class="hljs-string">'referrer'</span>],
};

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">bodyForLog</span>(<span class="hljs-params">req</span>) {
  <span class="hljs-keyword">const</span> fields = <span class="hljs-variable">BODY_FIELDS</span>[<span class="hljs-string">`<span class="hljs-subst">${req.method}</span> <span class="hljs-subst">${req.route?.path}</span>`</span>];
  <span class="hljs-keyword">if</span> (!fields || <span class="hljs-keyword">typeof</span> req.<span class="hljs-property">body</span> !== <span class="hljs-string">'object'</span>) <span class="hljs-keyword">return</span> <span class="hljs-literal">undefined</span>;
  <span class="hljs-keyword">return</span> <span class="hljs-title">Object</span>.<span class="hljs-title">fromEntries</span>(fields.<span class="hljs-title">filter</span>(<span class="hljs-function">(<span class="hljs-params">f</span>) =&gt;</span> f <span class="hljs-keyword">in</span> req.<span class="hljs-property">body</span>).<span class="hljs-title">map</span>(<span class="hljs-function">(<span class="hljs-params">f</span>) =&gt;</span> [f, req.<span class="hljs-property">body</span>[f]]));
}
</code></pre></div>
<p>A new field on <code>/signup</code> stays out of the logs until someone adds it on purpose. On a 10-person team, put this map next to the route definitions so reviewers see it in the same diff.</p>
<h2 id="when-you-need-to-correlate-personal-data">When you need to correlate personal data <a class="post-heading-anchor" href="#when-you-need-to-correlate-personal-data" aria-hidden="true">#</a></h2>
<p>Sometimes you need to know that two requests came from the same email without logging the email. Use a keyed hash, not a plain one. A plain SHA-256 of an email can be reversed by hashing a list of known emails.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> { createHmac } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:crypto'</span>;

<span class="hljs-keyword">const</span> key = process.<span class="hljs-property">env</span>.<span class="hljs-property">LOG_HASH_KEY</span>;
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title">pseudonym</span> = (<span class="hljs-params">value</span>) =&gt;
  <span class="hljs-title">createHmac</span>(<span class="hljs-string">'sha256'</span>, key).<span class="hljs-title">update</span>(value.<span class="hljs-title">trim</span>().<span class="hljs-title">toLowerCase</span>()).<span class="hljs-title">digest</span>(<span class="hljs-string">'hex'</span>).<span class="hljs-title">slice</span>(<span class="hljs-number">0</span>, <span class="hljs-number">16</span>);
</code></pre></div>
<h2 id="test-that-secrets-stay-out">Test that secrets stay out <a class="post-heading-anchor" href="#test-that-secrets-stay-out" aria-hidden="true">#</a></h2>
<p>Redaction rules rot. A test that runs a real request through the real logger catches regressions:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> { test } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:test'</span>;
<span class="hljs-keyword">import</span> assert <span class="hljs-keyword">from</span> <span class="hljs-string">'node:assert/strict'</span>;
<span class="hljs-keyword">import</span> { <span class="hljs-title">Writable</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:stream'</span>;
<span class="hljs-keyword">import</span> pino <span class="hljs-keyword">from</span> <span class="hljs-string">'pino'</span>;

<span class="hljs-title">test</span>(<span class="hljs-string">'authorization header is redacted'</span>, <span class="hljs-function">() =&gt;</span> {
  <span class="hljs-keyword">const</span> lines = [];
  <span class="hljs-keyword">const</span> sink = <span class="hljs-keyword">new</span> <span class="hljs-title">Writable</span>({ <span class="hljs-title">write</span>(<span class="hljs-params">chunk, _e, cb</span>) { lines.<span class="hljs-title">push</span>(chunk.<span class="hljs-title">toString</span>()); <span class="hljs-title">cb</span>(); } });
  <span class="hljs-keyword">const</span> log = <span class="hljs-title">pino</span>({ <span class="hljs-attr">redact</span>: { <span class="hljs-attr">paths</span>: [<span class="hljs-string">'req.headers.authorization'</span>], <span class="hljs-attr">censor</span>: <span class="hljs-string">'[REDACTED]'</span> } }, sink);
  log.<span class="hljs-title">info</span>({ <span class="hljs-attr">req</span>: { <span class="hljs-attr">headers</span>: { <span class="hljs-attr">authorization</span>: <span class="hljs-string">'Bearer secret-value-123'</span> } } }, <span class="hljs-string">'hello'</span>);
  assert.<span class="hljs-title">ok</span>(!lines.<span class="hljs-title">join</span>(<span class="hljs-string">''</span>).<span class="hljs-title">includes</span>(<span class="hljs-string">'secret-value-123'</span>));
});
</code></pre></div>
<p>Better still, import your real logger configuration in the test so the test and production can’t drift apart. If you use request context, the <a href="/blog/asynclocalstorage-request-id">AsyncLocalStorage request ID setup</a> gives you a user ID to log in place of personal data.</p>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<p>Forward this checklist to the team:</p>
<ul>
<li>Redact <code>Authorization</code>, <code>Cookie</code>, <code>Set-Cookie</code> and <code>X-Api-Key</code> in the logger config.</li>
<li>Serialize errors yourself; never log a raw HTTP client error.</li>
<li>Mask <code>token</code>, <code>code</code> and <code>signature</code> query parameters.</li>
<li>Log bodies only through a per-route allowlist.</li>
<li>Add one test that fails if a known secret appears in log output.</li>
<li>If a secret did reach the logs: rotate it first, then purge.</li>
</ul>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="does-pino-redact-slow-down-logging">Does Pino redact slow down logging? <a class="post-heading-anchor" href="#does-pino-redact-slow-down-logging" aria-hidden="true">#</a></h3>
<p>Pino compiles redaction paths once at startup, and the cost is small for a short list of explicit paths. Wildcard paths cost more than explicit ones, so prefer explicit paths for hot log lines.</p>
<h3 id="is-it-enough-to-redact-in-the-log-pipeline-instead-of-the-app">Is it enough to redact in the log pipeline instead of the app? <a class="post-heading-anchor" href="#is-it-enough-to-redact-in-the-log-pipeline-instead-of-the-app" aria-hidden="true">#</a></h3>
<p>It helps as a second layer, but the data has already left the process and may sit in buffers, agents or third-party services. Redact in the app first.</p>
<h3 id="what-should-i-do-if-secrets-are-already-in-my-logs">What should I do if secrets are already in my logs? <a class="post-heading-anchor" href="#what-should-i-do-if-secrets-are-already-in-my-logs" aria-hidden="true">#</a></h3>
<p>Rotate the credential immediately, since you can’t be sure who read it. Then delete or expire the affected logs and add a redaction rule and a test for that field.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you’d rather not maintain header redaction and body rules in every service, ApiTraffic masks credential headers such as <code>Authorization</code>, <code>Cookie</code> and <code>X-Api-Key</code> by default. <a href="https://www.apitraffic.io/docs/data/redactions">Redaction rules</a> for anything else, such as a <code>password</code> field or a card number, are set in the dashboard and pushed to your servers without a deploy, so a field you missed can be fixed in minutes rather than a release.</p>
<p>Setup is <a href="https://www.apitraffic.io/docs/integrations/express">one middleware after your body parsers</a>: <code>app.use(apitraffic.express())</code>, reading <code>API_TRAFFIC_TOKEN</code> and <code>API_TRAFFIC_BUCKET</code> from the environment.</p>
<p>Start free and see every request your API handles, minus the secrets: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20260105-redact-sensitive-data-logs">apitraffic.io</a></p>
<p>Sources: <a href="https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html" target="_blank" rel="noopener noreferrer">OWASP Logging Cheat Sheet</a>; <a href="https://getpino.io/#/docs/redaction" target="_blank" rel="noopener noreferrer">Pino redaction docs</a>; <a href="https://cwe.mitre.org/data/definitions/532.html" target="_blank" rel="noopener noreferrer">CWE-532: Insertion of Sensitive Information into Log File</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>Node.js graceful shutdown: stop dropping requests on every deploy</title>
      <link>https://www.apitraffic.io/blog/nodejs-graceful-shutdown</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/nodejs-graceful-shutdown</guid>
      <pubDate>Mon, 29 Dec 2025 14:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>A burst of 502s on every deploy is usually the old process dying with requests in flight. Here is the shutdown order and Express and Fastify code that fixes it in an afternoon.</description>
      <content:encoded><![CDATA[<p>Every deploy, a handful of customers get a 502 or a connection reset, and by the time anyone looks, the graphs are clean again. The cause is usually not the new code: it is the old process being killed with requests still in flight. Node.js graceful shutdown fixes it with a small amount of code: on <code>SIGTERM</code>, fail readiness, keep serving for a few seconds, call <code>server.close()</code>, wait for in-flight requests up to a deadline, then close the database pool and exit.</p>
<p>Below is why deploys drop requests, the exact order of operations, and working shutdown code for <a href="https://www.apitraffic.io/express">Express</a> and <a href="https://www.apitraffic.io/fastify">Fastify</a> that you can add in an afternoon.</p>
<h2 id="why-do-deploys-drop-requests-in-node-js">Why do deploys drop requests in Node.js? <a class="post-heading-anchor" href="#why-do-deploys-drop-requests-in-node-js" aria-hidden="true">#</a></h2>
<p>Every platform (Kubernetes, ECS, <a href="http://Fly.io" target="_blank" rel="noopener noreferrer">Fly.io</a>, Render, Railway, Heroku) does roughly the same thing when it replaces your process:</p>
<ol>
<li>Sends <code>SIGTERM</code>.</li>
<li>Waits a grace period. <a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination" target="_blank" rel="noopener noreferrer">Kubernetes defaults to 30 seconds</a>; <code>docker stop</code> to 10. PaaS platforms set their own, so check yours.</li>
<li>Sends <code>SIGKILL</code>, which cannot be caught.</li>
</ol>
<p>The catch is that removing your instance from the load balancer happens <strong>at the same time</strong> as the <code>SIGTERM</code>, not before. In Kubernetes, the pod leaves the Service endpoints while the signal is delivered, and proxies take a few seconds to notice. New requests can still arrive after your process decided to stop.</p>
<p>That leads to four common failures:</p>
<ul>
<li><strong>No signal handler.</strong> Node exits immediately on <code>SIGTERM</code> and cuts every in-flight request. As PID 1 in a container it is worse: the signal is ignored, the grace period runs out, and you get <code>SIGKILL</code> plus a slow deploy.</li>
<li><strong>Closing the listener too early.</strong> Requests routed to you during the load balancer’s delay get connection errors.</li>
<li><strong>Keep-alive reuse.</strong> A client sends a new request on an open keep-alive connection after shutdown starts.</li>
<li><strong>Closing the database first.</strong> The pool closes before requests finish, and you get a burst of 500s.</li>
</ul>
<h2 id="node-js-graceful-shutdown-the-order-that-works">Node.js graceful shutdown: the order that works <a class="post-heading-anchor" href="#node-js-graceful-shutdown-the-order-that-works" aria-hidden="true">#</a></h2>
<h3 id="1-fail-readiness">1. Fail readiness <a class="post-heading-anchor" href="#1-fail-readiness" aria-hidden="true">#</a></h3>
<p>Your <a href="/blog/nodejs-health-check">readiness endpoint</a> returns 503 so health checks stop routing traffic to you.</p>
<h3 id="2-keep-serving-for-5-to-10-seconds">2. Keep serving for 5 to 10 seconds <a class="post-heading-anchor" href="#2-keep-serving-for-5-to-10-seconds" aria-hidden="true">#</a></h3>
<p>Let the load balancer catch up. Requests that still arrive are served normally.</p>
<h3 id="3-stop-accepting-connections">3. Stop accepting connections <a class="post-heading-anchor" href="#3-stop-accepting-connections" aria-hidden="true">#</a></h3>
<p>Call <code>server.close()</code>. <a href="https://nodejs.org/api/http.html" target="_blank" rel="noopener noreferrer">Since Node 19 it also closes idle keep-alive connections</a>, then waits for active requests before calling back.</p>
<h3 id="4-enforce-a-deadline">4. Enforce a deadline <a class="post-heading-anchor" href="#4-enforce-a-deadline" aria-hidden="true">#</a></h3>
<p>If requests are still running at the deadline, <code>server.closeAllConnections()</code> (Node 18.2+) force-closes them. The deadline must be shorter than the platform’s grace period, so you exit with logs flushed instead of being killed mid-write.</p>
<h3 id="5-close-dependencies-then-exit">5. Close dependencies, then exit <a class="post-heading-anchor" href="#5-close-dependencies-then-exit" aria-hidden="true">#</a></h3>
<p>Stop queue consumers and intervals, close the database pool and Redis client, exit 0.</p>
<h2 id="express-graceful-shutdown-example">Express graceful shutdown example <a class="post-heading-anchor" href="#express-graceful-shutdown-example" aria-hidden="true">#</a></h2>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> { <span class="hljs-built_in">setTimeout</span> <span class="hljs-keyword">as</span> sleep } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:timers/promises'</span>;
<span class="hljs-keyword">import</span> { pool } <span class="hljs-keyword">from</span> <span class="hljs-string">'./db.js'</span>;

<span class="hljs-keyword">const</span> app = <span class="hljs-title">express</span>();
<span class="hljs-keyword">let</span> shuttingDown = <span class="hljs-literal">false</span>;

app.<span class="hljs-title">get</span>(<span class="hljs-string">'/ready'</span>, <span class="hljs-function">(<span class="hljs-params">req, res</span>) =&gt;</span> {
  res.<span class="hljs-title">status</span>(shuttingDown ? <span class="hljs-number">503</span> : <span class="hljs-number">200</span>).<span class="hljs-title">send</span>(shuttingDown ? <span class="hljs-string">'draining'</span> : <span class="hljs-string">'ok'</span>);
});

<span class="hljs-comment">// While draining, ask keep-alive clients to reconnect elsewhere.</span>
app.<span class="hljs-title">use</span>(<span class="hljs-function">(<span class="hljs-params">req, res, next</span>) =&gt;</span> {
  <span class="hljs-keyword">if</span> (shuttingDown) res.<span class="hljs-title">set</span>(<span class="hljs-string">'Connection'</span>, <span class="hljs-string">'close'</span>);
  <span class="hljs-title">next</span>();
});

app.<span class="hljs-title">get</span>(<span class="hljs-string">'/orders/:id'</span>, <span class="hljs-title">async</span> (req, res) =&gt; {
  <span class="hljs-keyword">const</span> { rows } = <span class="hljs-keyword">await</span> pool.<span class="hljs-title">query</span>(<span class="hljs-string">'select * from orders where id = $1'</span>, [req.<span class="hljs-property">params</span>.<span class="hljs-property">id</span>]);
  res.<span class="hljs-title">json</span>(rows[<span class="hljs-number">0</span>] ?? <span class="hljs-literal">null</span>);
});

<span class="hljs-keyword">const</span> server = app.<span class="hljs-title">listen</span>(process.<span class="hljs-property">env</span>.<span class="hljs-property">PORT</span> ?? <span class="hljs-number">3000</span>);

<span class="hljs-keyword">const</span> <span class="hljs-variable">DRAIN_DELAY_MS</span> = <span class="hljs-number">5_000</span>;
<span class="hljs-keyword">const</span> <span class="hljs-variable">HARD_DEADLINE_MS</span> = <span class="hljs-number">20_000</span>; <span class="hljs-comment">// under a 30s grace period</span>

<span class="hljs-keyword">async</span> <span class="hljs-keyword">function</span> <span class="hljs-title">shutdown</span>(<span class="hljs-params">signal</span>) {
  <span class="hljs-keyword">if</span> (shuttingDown) <span class="hljs-keyword">return</span>;
  shuttingDown = <span class="hljs-literal">true</span>;
  <span class="hljs-variable">console</span>.<span class="hljs-title">log</span>(<span class="hljs-string">`<span class="hljs-subst">${signal}</span> received, draining`</span>);

  <span class="hljs-built_in">setTimeout</span>(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-variable">console</span>.<span class="hljs-title">error</span>(<span class="hljs-string">'shutdown deadline hit, forcing close'</span>);
    server.<span class="hljs-title">closeAllConnections</span>();
    process.<span class="hljs-title">exit</span>(<span class="hljs-number">1</span>);
  }, <span class="hljs-variable">HARD_DEADLINE_MS</span>).<span class="hljs-title">unref</span>();

  <span class="hljs-keyword">await</span> <span class="hljs-title">sleep</span>(<span class="hljs-variable">DRAIN_DELAY_MS</span>);
  <span class="hljs-keyword">await</span> <span class="hljs-keyword">new</span> <span class="hljs-title">Promise</span>(<span class="hljs-function">(<span class="hljs-params">resolve</span>) =&gt;</span> server.<span class="hljs-title">close</span>(resolve)); <span class="hljs-comment">// waits for in-flight requests</span>
  <span class="hljs-keyword">await</span> pool.<span class="hljs-title">end</span>();
  process.<span class="hljs-title">exit</span>(<span class="hljs-number">0</span>);
}

process.<span class="hljs-title">on</span>(<span class="hljs-string">'SIGTERM'</span>, <span class="hljs-function">() =&gt;</span> <span class="hljs-title">shutdown</span>(<span class="hljs-string">'SIGTERM'</span>));
process.<span class="hljs-title">on</span>(<span class="hljs-string">'SIGINT'</span>, <span class="hljs-function">() =&gt;</span> <span class="hljs-title">shutdown</span>(<span class="hljs-string">'SIGINT'</span>));
</code></pre></div>
<p><code>server.close()</code> calls back only after active requests finish, so the pool closes only when nothing is using it. If you tag logs with <a href="/blog/asynclocalstorage-request-id">request IDs</a>, log the count of open requests at each step; it makes the first real rollout much easier to read.</p>
<h2 id="fastify-graceful-shutdown-example">Fastify graceful shutdown example <a class="post-heading-anchor" href="#fastify-graceful-shutdown-example" aria-hidden="true">#</a></h2>
<p>Fastify does much of this for you. <code>fastify.close()</code> stops the server, runs <code>onClose</code> hooks, and answers requests that arrive after <code>close()</code> with a 503 (<a href="https://fastify.dev/docs/latest/Reference/Server/" target="_blank" rel="noopener noreferrer"><code>return503OnClosing</code> defaults to <code>true</code></a>). <code>forceCloseConnections</code> defaults to <code>"idle"</code> where supported, so idle keep-alive connections close for you. Because late requests get a 503, keep the drain delay before calling <code>close()</code>:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> <span class="hljs-title">Fastify</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'fastify'</span>;
<span class="hljs-keyword">import</span> { <span class="hljs-built_in">setTimeout</span> <span class="hljs-keyword">as</span> sleep } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:timers/promises'</span>;
<span class="hljs-keyword">import</span> { pool } <span class="hljs-keyword">from</span> <span class="hljs-string">'./db.js'</span>;

<span class="hljs-keyword">const</span> app = <span class="hljs-title">Fastify</span>({ <span class="hljs-attr">logger</span>: <span class="hljs-literal">true</span> });
<span class="hljs-keyword">let</span> draining = <span class="hljs-literal">false</span>;

app.<span class="hljs-title">get</span>(<span class="hljs-string">'/ready'</span>, <span class="hljs-title">async</span> (req, reply) =&gt; (draining ? reply.<span class="hljs-title">code</span>(<span class="hljs-number">503</span>).<span class="hljs-title">send</span>(<span class="hljs-string">'draining'</span>) : <span class="hljs-string">'ok'</span>));
app.<span class="hljs-title">addHook</span>(<span class="hljs-string">'onClose'</span>, <span class="hljs-title">async</span> () =&gt; { <span class="hljs-keyword">await</span> pool.<span class="hljs-title">end</span>(); });

<span class="hljs-keyword">await</span> app.<span class="hljs-title">listen</span>({ <span class="hljs-attr">port</span>: <span class="hljs-title">Number</span>(process.<span class="hljs-property">env</span>.<span class="hljs-property">PORT</span> ?? <span class="hljs-number">3000</span>), <span class="hljs-attr">host</span>: <span class="hljs-string">'0.0.0.0'</span> });

<span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> signal <span class="hljs-keyword">of</span> [<span class="hljs-string">'SIGTERM'</span>, <span class="hljs-string">'SIGINT'</span>]) {
  process.<span class="hljs-title">once</span>(signal, <span class="hljs-title">async</span> () =&gt; {
    draining = <span class="hljs-literal">true</span>;
    <span class="hljs-built_in">setTimeout</span>(<span class="hljs-function">() =&gt;</span> process.<span class="hljs-title">exit</span>(<span class="hljs-number">1</span>), <span class="hljs-number">20_000</span>).<span class="hljs-title">unref</span>();
    <span class="hljs-keyword">await</span> <span class="hljs-title">sleep</span>(<span class="hljs-number">5_000</span>);
    <span class="hljs-keyword">await</span> app.<span class="hljs-title">close</span>();
    process.<span class="hljs-title">exit</span>(<span class="hljs-number">0</span>);
  });
}
</code></pre></div>
<h2 id="containers-pid-1-and-background-work">Containers, PID 1 and background work <a class="post-heading-anchor" href="#containers-pid-1-and-background-work" aria-hidden="true">#</a></h2>
<ul>
<li><strong>Run node directly.</strong> Use the exec form <code>CMD ["node", "server.js"]</code>. A shell-form <code>CMD</code> or a shell-wrapped start script may not forward <code>SIGTERM</code>.</li>
<li><strong>Use an init.</strong> <code>docker run --init</code> or <code>tini</code> as the entrypoint delivers signals properly to a process running as PID 1 (see the <a href="https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md" target="_blank" rel="noopener noreferrer">Docker and Node.js best practices</a>).</li>
<li><strong>Make the numbers fit.</strong> Drain delay + longest normal request &lt; hard deadline &lt; grace period. With the values above: 5 + 15 ≤ 20 &lt; 30.</li>
<li><strong>Fail readiness, not liveness.</strong> Failing liveness gets the container restarted mid-drain.</li>
<li><strong>Background work.</strong> Stop pulling queue messages, finish and acknowledge the current one, clear intervals, flush batch writers before exit.</li>
</ul>
<p>A solo developer on a PaaS can stop at the Express or Fastify code above. A 10-person team on Kubernetes should also agree on one grace period across services and put the shutdown function in a shared package so every service drains the same way.</p>
<h2 id="how-to-test-graceful-shutdown">How to test graceful shutdown <a class="post-heading-anchor" href="#how-to-test-graceful-shutdown" aria-hidden="true">#</a></h2>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">bash</span></div><pre><code class="language-bash hljs">node server.js &amp; PID=$!
<span class="hljs-built_in">sleep</span> 1
npx autocannon -c 50 -d 15 http://localhost:3000/orders/1 &amp;
<span class="hljs-built_in">sleep</span> 5 &amp;&amp; <span class="hljs-built_in">kill</span> -TERM <span class="hljs-variable">$PID</span>
<span class="hljs-built_in">wait</span>
</code></pre></div>
<p>With one local process, requests arriving after <code>close()</code> fail because there is nowhere else to go. What matters is that requests in flight when the signal arrived all succeed. For the real test, run the same load through your load balancer during a rollout and expect zero errors. If you just moved to Node.js 24 LTS, this is a good test to add while you are in there.</p>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<ul>
<li>Add a <code>SIGTERM</code> handler that waits 5 seconds, calls <code>server.close()</code>, then closes the pool.</li>
<li>Add a hard deadline shorter than your platform’s grace period.</li>
<li>Use exec-form <code>CMD ["node", ...]</code> and an init process in containers.</li>
<li>Watch 5xx and <a href="/blog/api-latency-percentiles">p95/p99 latency</a> for the five minutes after your next deploy.</li>
</ul>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="what-does-server-close-do-in-node-js">What does server.close() do in Node.js? <a class="post-heading-anchor" href="#what-does-server-close-do-in-node-js" aria-hidden="true">#</a></h3>
<p>It stops the server accepting new connections and, since Node 19, closes idle keep-alive connections. Its callback runs once active requests have finished, so it waits instead of cutting them off.</p>
<h3 id="how-long-should-a-graceful-shutdown-take">How long should a graceful shutdown take? <a class="post-heading-anchor" href="#how-long-should-a-graceful-shutdown-take" aria-hidden="true">#</a></h3>
<p>Long enough for the load balancer to stop routing (5 to 10 seconds) plus your slowest normal request, and always under the platform’s grace period. For most APIs, 15 to 25 seconds total is plenty.</p>
<h3 id="why-does-my-node-app-ignore-sigterm-in-docker">Why does my Node app ignore SIGTERM in Docker? <a class="post-heading-anchor" href="#why-does-my-node-app-ignore-sigterm-in-docker" aria-hidden="true">#</a></h3>
<p>As PID 1 without a handler, Node does not get the default signal behavior, so <code>SIGTERM</code> does nothing until <code>SIGKILL</code> arrives. Add a handler and run with <code>--init</code> or <code>tini</code>.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>Dropped requests during deploys last seconds and are easy to miss. If you would rather not build a dashboard to check every rollout, ApiTraffic logs every request and response from one middleware.</p>
<ul>
<li>Record a <a href="https://www.apitraffic.io/docs/events">deploy event</a> on each rollout, then <a href="https://www.apitraffic.io/docs/traffic/search">search</a> <code>status_code &gt;= 500</code> or <code>status_code == 503</code> around it. A working shutdown makes that window look like any other.</li>
<li>Regressions compares p95 before and after the deploy, which also catches a new instance that is slow while warming up.</li>
</ul>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20251229-nodejs-graceful-shutdown">apitraffic.io</a></p>
<p>Sources: <a href="https://nodejs.org/api/http.html" target="_blank" rel="noopener noreferrer">Node.js HTTP docs (server.close, closeAllConnections)</a> ; <a href="https://fastify.dev/docs/latest/Reference/Server/" target="_blank" rel="noopener noreferrer">Fastify Server reference</a> ; <a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination" target="_blank" rel="noopener noreferrer">Kubernetes Pod Lifecycle, termination of Pods</a> ; <a href="https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md" target="_blank" rel="noopener noreferrer">Docker and Node.js best practices</a></p>
]]></content:encoded>
    </item>
    <item>
      <title>API latency percentiles: how to read p50, p95 and p99 without fooling yourself</title>
      <link>https://www.apitraffic.io/blog/api-latency-percentiles</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/api-latency-percentiles</guid>
      <pubDate>Thu, 18 Dec 2025 14:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>Your average latency says 102 ms; a customer says the app hangs for two seconds. Both are true. Learn to read p50, p95 and p99, avoid the four classic mistakes, and record percentiles per route in Node.</description>
      <content:encoded><![CDATA[<p>Your dashboard says average latency is 102 ms, and a customer says the app “hangs for two seconds” several times a day. Both are true. API latency percentiles explain the gap: the p50 tells you what a typical request feels like, the p95 and p99 tell you what your unluckiest users feel, and the average hides both.</p>
<p>The direct answer: p95 is the latency that 95% of requests beat; p99 is the latency that 99% beat. Track them per route, never average them, and check the request count before trusting a p99. Below are the four common ways percentiles mislead, and a Node middleware that records them properly.</p>
<h2 id="what-do-p50-p95-and-p99-latency-mean">What do p50, p95 and p99 latency mean? <a class="post-heading-anchor" href="#what-do-p50-p95-and-p99-latency-mean" aria-hidden="true">#</a></h2>
<p>Sort every request’s duration from fastest to slowest. The p50 (median) is the value halfway down the list: half of requests were faster. The p95 is the value 95% of the way down: 1 in 20 requests was slower. The p99: 1 in 100 was slower.</p>
<p>A percentile says nothing about how much slower the slow ones were. The p99 is a threshold, not the worst case.</p>
<p>Here is the simplest correct implementation, using the nearest-rank method:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">function</span> <span class="hljs-title">percentile</span>(<span class="hljs-params">sorted, p</span>) {
  <span class="hljs-keyword">if</span> (sorted.<span class="hljs-property">length</span> === <span class="hljs-number">0</span>) <span class="hljs-keyword">return</span> <span class="hljs-title">NaN</span>;
  <span class="hljs-keyword">const</span> rank = <span class="hljs-title">Math</span>.<span class="hljs-title">ceil</span>((p / <span class="hljs-number">100</span>) * sorted.<span class="hljs-property">length</span>);
  <span class="hljs-keyword">return</span> sorted[<span class="hljs-title">Math</span>.<span class="hljs-title">max</span>(<span class="hljs-number">0</span>, rank - <span class="hljs-number">1</span>)];
}

<span class="hljs-keyword">function</span> <span class="hljs-title">summarize</span>(<span class="hljs-params">samples</span>) {
  <span class="hljs-keyword">const</span> s = [...samples].<span class="hljs-title">sort</span>(<span class="hljs-function">(<span class="hljs-params">a, b</span>) =&gt;</span> a - b);
  <span class="hljs-keyword">const</span> mean = s.<span class="hljs-title">reduce</span>(<span class="hljs-function">(<span class="hljs-params">a, b</span>) =&gt;</span> a + b, <span class="hljs-number">0</span>) / s.<span class="hljs-property">length</span>;
  <span class="hljs-keyword">return</span> {
    <span class="hljs-attr">n</span>: s.<span class="hljs-property">length</span>,
    <span class="hljs-attr">mean</span>: <span class="hljs-title">Math</span>.<span class="hljs-title">round</span>(mean),
    <span class="hljs-attr">p50</span>: <span class="hljs-title">percentile</span>(s, <span class="hljs-number">50</span>),
    <span class="hljs-attr">p95</span>: <span class="hljs-title">percentile</span>(s, <span class="hljs-number">95</span>),
    <span class="hljs-attr">p99</span>: <span class="hljs-title">percentile</span>(s, <span class="hljs-number">99</span>),
    <span class="hljs-attr">max</span>: s.<span class="hljs-title">at</span>(-<span class="hljs-number">1</span>),
  };
}
</code></pre></div>
<h2 id="average-vs-percentile-latency-why-the-mean-is-not-enough">Average vs percentile latency: why the mean is not enough <a class="post-heading-anchor" href="#average-vs-percentile-latency-why-the-mean-is-not-enough" aria-hidden="true">#</a></h2>
<p>Take 1,000 requests where 970 take 40 to 60 ms and 30 hit a slow path taking 1.2 to 2.4 seconds. Run them through <code>summarize()</code>:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">text</span></div><pre><code class="language-text hljs">{ n: 1000, mean: 102, p50: 50, p95: 60, p99: 1960, max: 2360 }
</code></pre></div>
<p>The mean of 102 ms describes no request that actually happened. The percentiles tell the real story: the typical request is fine (p50 50 ms), 95% are fine (p95 60 ms), and somewhere between the 95th and 99th percentile there is a cliff. That cliff is the thing to investigate.</p>
<h2 id="why-tail-latency-matters-more-than-it-looks">Why tail latency matters more than it looks <a class="post-heading-anchor" href="#why-tail-latency-matters-more-than-it-looks" aria-hidden="true">#</a></h2>
<p>“Only 1% of requests” sounds small until you count requests per user. A single page load or mobile screen often triggers several API calls, and a user session triggers dozens. If each call independently has a 1% chance of being slow, a screen that makes 10 calls hits at least one slow call about 10% of the time.</p>
<p>The same arithmetic applies inside your service. Dean and Barroso’s <a href="https://pdos.csail.mit.edu/6.824/papers/tail-dean.pdf" target="_blank" rel="noopener noreferrer">“The Tail at Scale”</a> (Communications of the ACM, 2013) works through the fan-out case: if a request waits on 100 backend calls and each has a 1-in-100 chance of being slow, about 63% of requests will be slow (<code>1 - 0.99^100</code>). A Node API calling a database, a cache and two third-party APIs per request is a small version of this.</p>
<h2 id="mistake-1-averaging-api-latency-percentiles">Mistake 1: averaging API latency percentiles <a class="post-heading-anchor" href="#mistake-1-averaging-api-latency-percentiles" aria-hidden="true">#</a></h2>
<p>You cannot average p95s across instances, endpoints or time windows and get a p95. Two instances behind a load balancer:</p>
<ul>
<li>Instance A: 1,000 requests, 900 at 50 ms and 100 at 900 ms. p95 = 900 ms.</li>
<li>Instance B: 100 requests at 60 ms. p95 = 60 ms.</li>
</ul>
<p>The average of the two p95s is 480 ms. The real p95 across all 1,100 requests is 900 ms. The same error happens when a dashboard shows “p95 for the day” by averaging 24 hourly p95s, or “p95 for the API” by averaging endpoint p95s.</p>
<p>To combine, you need the underlying distribution: raw durations or mergeable histograms (fixed buckets you can add together, as <a href="https://prometheus.io/docs/practices/histograms/" target="_blank" rel="noopener noreferrer">Prometheus</a> and HDR histograms allow).</p>
<h2 id="mistake-2-percentiles-from-too-few-requests">Mistake 2: percentiles from too few requests <a class="post-heading-anchor" href="#mistake-2-percentiles-from-too-few-requests" aria-hidden="true">#</a></h2>
<p>A p99 over 50 requests is the single slowest request, give or take. A p99 over 200 is the second slowest. One garbage-collection pause or cold connection moves it. Before reading a percentile, check the count behind it. Rough rules:</p>
<ul>
<li>p50 is meaningful with a few dozen samples.</li>
<li>p95 needs a few hundred to be stable.</li>
<li>p99 needs a few thousand.</li>
</ul>
<p>Most small-team APIs have many low-traffic endpoints. For those, widen the window to an hour or a day rather than staring at a noisy p99 line.</p>
<h2 id="mistake-3-one-number-for-the-whole-api">Mistake 3: one number for the whole API <a class="post-heading-anchor" href="#mistake-3-one-number-for-the-whole-api" aria-hidden="true">#</a></h2>
<p>An API-wide p95 mixes <code>GET /healthz</code> (1 ms), <code>POST /v1/login</code> (deliberately slow password hashing) and <code>GET /v1/reports/export</code> (seconds). Changes in traffic mix move the number without anything getting faster or slower. Break latency out by:</p>
<ul>
<li><strong>Route pattern</strong>, such as <code>/v1/orders/:id</code>, not the raw URL with IDs in it.</li>
<li><strong>Status class.</strong> A burst of fast 401s or 404s from a scanner pulls p50 down and makes things look better. Errors that time out push the tail up. Look at 2xx latency separately.</li>
</ul>
<h2 id="mistake-4-measuring-in-the-wrong-place">Mistake 4: measuring in the wrong place <a class="post-heading-anchor" href="#mistake-4-measuring-in-the-wrong-place" aria-hidden="true">#</a></h2>
<p>Where the timer starts and stops decides what the number includes.</p>
<ul>
<li><strong>Inside a handler</strong> misses time spent in middleware, body parsing and waiting on a busy event loop before the handler ran.</li>
<li><strong>In a top-level middleware</strong> (the example below) covers most of your process’s work, but not time spent queued in the load balancer or kernel before Node accepted the connection.</li>
<li><strong>At the client or edge</strong> includes network time and queueing. It is closest to what users feel.</li>
</ul>
<p>Load tests have their own trap, which Gil Tene calls coordinated omission: a client that waits for each response sends fewer requests while the server is slow, so the slow period is underrepresented. Fixed-rate tools such as <a href="https://github.com/giltene/wrk2" target="_blank" rel="noopener noreferrer">wrk2</a> give more honest tails.</p>
<h2 id="how-to-calculate-p95-and-p99-in-node-js-without-storing-every-request">How to calculate p95 and p99 in Node.js without storing every request <a class="post-heading-anchor" href="#how-to-calculate-p95-and-p99-in-node-js-without-storing-every-request" aria-hidden="true">#</a></h2>
<p>Node ships an <a href="https://nodejs.org/api/perf_hooks.html" target="_blank" rel="noopener noreferrer">HDR histogram in <code>perf_hooks</code></a>. It records values into buckets with fixed relative precision, so memory stays small and you can ask for any percentile.</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> { createHistogram } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:perf_hooks'</span>;

<span class="hljs-keyword">const</span> histograms = <span class="hljs-keyword">new</span> <span class="hljs-title">Map</span>(); <span class="hljs-comment">// "GET /v1/orders/:id 2xx" -&gt; histogram</span>

<span class="hljs-keyword">function</span> <span class="hljs-title">latencyHistograms</span>(<span class="hljs-params">req, res, next</span>) {
  <span class="hljs-keyword">const</span> start = process.<span class="hljs-property">hrtime</span>.<span class="hljs-title">bigint</span>();
  res.<span class="hljs-title">on</span>(<span class="hljs-string">'finish'</span>, <span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">const</span> micros = <span class="hljs-title">Number</span>((process.<span class="hljs-property">hrtime</span>.<span class="hljs-title">bigint</span>() - start) / <span class="hljs-number">1000n</span>);
    <span class="hljs-keyword">const</span> route = req.<span class="hljs-property">route</span> ? req.<span class="hljs-property">baseUrl</span> + req.<span class="hljs-property">route</span>.<span class="hljs-property">path</span> : <span class="hljs-string">'unmatched'</span>;
    <span class="hljs-keyword">const</span> key = <span class="hljs-string">`<span class="hljs-subst">${req.method}</span> <span class="hljs-subst">${route}</span> <span class="hljs-subst">${<span class="hljs-built_in">Math</span>.floor(res.statusCode / <span class="hljs-number">100</span>)}</span>xx`</span>;
    <span class="hljs-keyword">let</span> h = histograms.<span class="hljs-title">get</span>(key);
    <span class="hljs-keyword">if</span> (!h) histograms.<span class="hljs-title">set</span>(key, (h = <span class="hljs-title">createHistogram</span>()));
    h.<span class="hljs-title">record</span>(<span class="hljs-title">Math</span>.<span class="hljs-title">max</span>(<span class="hljs-number">1</span>, micros));
  });
  <span class="hljs-title">next</span>();
}

<span class="hljs-keyword">function</span> <span class="hljs-title">report</span>(<span class="hljs-params"></span>) {
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">const</span> [key, h] <span class="hljs-keyword">of</span> histograms) {
    <span class="hljs-keyword">const</span> <span class="hljs-title">ms</span> = (<span class="hljs-params">p</span>) =&gt; (h.<span class="hljs-title">percentile</span>(p) / <span class="hljs-number">1000</span>).<span class="hljs-title">toFixed</span>(<span class="hljs-number">1</span>);
    <span class="hljs-variable">console</span>.<span class="hljs-title">log</span>(
      <span class="hljs-string">`<span class="hljs-subst">${key.padEnd(<span class="hljs-number">32</span>)}</span> n=<span class="hljs-subst">${<span class="hljs-built_in">String</span>(h.count).padStart(<span class="hljs-number">6</span>)}</span> `</span> +
        <span class="hljs-string">`p50=<span class="hljs-subst">${ms(<span class="hljs-number">50</span>)}</span>ms p95=<span class="hljs-subst">${ms(<span class="hljs-number">95</span>)}</span>ms p99=<span class="hljs-subst">${ms(<span class="hljs-number">99</span>)}</span>ms max=<span class="hljs-subst">${(h.max / <span class="hljs-number">1000</span>).toFixed(<span class="hljs-number">1</span>)}</span>ms`</span>
    );
    h.<span class="hljs-title">reset</span>();
  }
}
<span class="hljs-built_in">setInterval</span>(report, <span class="hljs-number">60_000</span>).<span class="hljs-title">unref</span>();

<span class="hljs-keyword">const</span> app = <span class="hljs-title">express</span>();
app.<span class="hljs-title">use</span>(latencyHistograms);
</code></pre></div>
<p>A run with 200 requests where 2% hit a 300 ms delay prints something like:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">text</span></div><pre><code class="language-text hljs">GET /v1/orders/:id 2xx           n=   200 p50=5.7ms p95=6.3ms p99=301.3ms max=301.8ms
GET unmatched 4xx                n=     1 p50=1.3ms p95=1.3ms p99=1.3ms max=1.3ms
</code></pre></div>
<p><code>req.route</code> is only set after routing, which is why the key is computed in the <code>finish</code> handler. The count sits next to every percentile so you can see when it is too small to trust. For a solo developer on one instance, logging these lines (for example through <a href="/blog/pino-logging-nodejs">Pino logging</a>) is enough. A team running several instances should ship the histograms to a metrics system so they can be merged. To dig into one slow request, a <a href="/blog/asynclocalstorage-request-id">request ID that follows it through the code</a> is what connects the percentile to the cause.</p>
<h2 id="checklist-reading-a-latency-chart-in-30-seconds">Checklist: reading a latency chart in 30 seconds <a class="post-heading-anchor" href="#checklist-reading-a-latency-chart-in-30-seconds" aria-hidden="true">#</a></h2>
<ol>
<li>What is the sample count behind this line?</li>
<li>Is this one route, or a blend? Which statuses are included?</li>
<li>Where was the timer: handler, middleware, edge or client?</li>
<li>Is this a real percentile over raw data, or a rollup of percentiles?</li>
<li>Did p50 and p99 move together (everything got slower) or separately (a new slow path for some requests)?</li>
</ol>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="what-is-a-good-p99-latency-for-an-api">What is a good p99 latency for an API? <a class="post-heading-anchor" href="#what-is-a-good-p99-latency-for-an-api" aria-hidden="true">#</a></h3>
<p>There is no universal number; it depends on what the endpoint does. A useful start is to compare p99 with p50: a p99 within a few times the p50 is healthy, while a p99 ten or more times the p50 points at a specific slow path worth finding.</p>
<h3 id="is-p95-or-p99-more-important">Is p95 or p99 more important? <a class="post-heading-anchor" href="#is-p95-or-p99-more-important" aria-hidden="true">#</a></h3>
<p>Watch both. p95 is stable enough to track trends on modest traffic. p99 catches problems that affect real users several times a day but needs thousands of requests per window to be trustworthy.</p>
<h3 id="can-i-average-p95-values-across-servers">Can I average p95 values across servers? <a class="post-heading-anchor" href="#can-i-average-p95-values-across-servers" aria-hidden="true">#</a></h3>
<p>No. The average of percentiles is not a percentile, and it usually understates the tail. Merge the underlying histograms or raw durations, then compute the percentile once.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you would rather not build and run histograms yourself, ApiTraffic’s <a href="https://www.apitraffic.io/docs/endpoint-handling">endpoint details</a> show requests, p50, p95 and p99 per endpoint over time, grouped by route pattern (for example <code>/api/v1/users/{id}</code>), so the request count and the per-route split are already there.</p>
<p>When the p99 jumps, the <a href="https://www.apitraffic.io/docs/apm/analytics">Time Breakdown</a> shows where an endpoint’s time goes and the request waterfall shows each query and outbound call on one slow request’s timeline. Spans are recorded through <a href="https://www.apitraffic.io/docs/apm/setup">OpenTelemetry</a>, loaded before your database drivers:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">bash</span></div><pre><code class="language-bash hljs">node -r @apitraffic/node/register ./server.js
</code></pre></div>
<p>Start free and see the p95 of every endpoint you run: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20251218-api-latency-percentiles">apitraffic.io</a></p>
<p>Sources: <a href="https://pdos.csail.mit.edu/6.824/papers/tail-dean.pdf" target="_blank" rel="noopener noreferrer">Dean and Barroso, “The Tail at Scale”, CACM, Feb 2013</a>; <a href="https://github.com/giltene/wrk2" target="_blank" rel="noopener noreferrer">wrk2 README on coordinated omission</a>; <a href="https://nodejs.org/api/perf_hooks.html" target="_blank" rel="noopener noreferrer">Node.js perf_hooks: createHistogram()</a>; <a href="https://prometheus.io/docs/practices/histograms/" target="_blank" rel="noopener noreferrer">Prometheus: Histograms and summaries</a>.</p>
]]></content:encoded>
    </item>
    <item>
      <title>AsyncLocalStorage request IDs: follow one request through a Node.js API</title>
      <link>https://www.apitraffic.io/blog/asynclocalstorage-request-id</link>
      <guid isPermaLink="true">https://www.apitraffic.io/blog/asynclocalstorage-request-id</guid>
      <pubDate>Mon, 24 Nov 2025 14:00:00 GMT</pubDate>
      <dc:creator>Jason Fill</dc:creator>
      <description>A customer says checkout failed at 2pm and your logs are fifty requests interleaved. Add request IDs with AsyncLocalStorage in Express or Fastify in an afternoon, and every log line, response and downstream call carries the same ID.</description>
      <content:encoded><![CDATA[<p>A customer emails: “checkout failed around 2pm”. You open the logs and find four hundred lines from 2pm, interleaved from fifty concurrent requests, with nothing tying them together. The fix is an AsyncLocalStorage request ID: generate one ID per request, store it in <code>AsyncLocalStorage</code>, and have your logger read it so every line carries it automatically.</p>
<p>In short: create the ID in your first middleware, run the rest of the request inside <code>als.run()</code>, add the ID to log lines with a Pino <code>mixin</code>, return it in an <code>x-request-id</code> response header, and <a href="/blog/log-outbound-http-requests">forward it on outbound calls</a>. That is an afternoon of work for a 1-person team, and it pays off the first time someone quotes an ID from an error response. If you haven’t settled on a logger yet, start with <a href="/blog/pino-logging-nodejs">structured logging with Pino</a>.</p>
<h2 id="what-a-request-id-needs-to-do">What a request ID needs to do <a class="post-heading-anchor" href="#what-a-request-id-needs-to-do" aria-hidden="true">#</a></h2>
<div class="post-table"><table>
<thead>
<tr>
<th>Job</th>
<th>Why it matters</th>
</tr>
</thead>
<tbody>
<tr>
<td>Appear on every log line for the request</td>
<td>Including the database layer and error handler, not just the route</td>
</tr>
<tr>
<td>Come back in a response header</td>
<td>Users and support tickets can quote it</td>
</tr>
<tr>
<td>Travel to downstream services</td>
<td>You can follow the request into the next service’s logs</td>
</tr>
<tr>
<td>Be validated if accepted from a header</td>
<td>Otherwise anyone can write arbitrary strings into your logs</td>
</tr>
</tbody>
</table></div>
<p>The first job is the hard one. Threading a <code>requestId</code> argument through every function works for a week, then someone adds a helper that doesn’t take it.</p>
<h2 id="how-asynclocalstorage-works-in-node-js">How AsyncLocalStorage works in Node.js <a class="post-heading-anchor" href="#how-asynclocalstorage-works-in-node-js" aria-hidden="true">#</a></h2>
<p><a href="https://nodejs.org/api/async_context.html" target="_blank" rel="noopener noreferrer"><code>AsyncLocalStorage</code></a> (from <code>node:async_hooks</code>) gives you a store that follows asynchronous execution. Call <code>als.run(store, fn)</code>, and anything <code>fn</code> triggers, including awaited promises, timers and callbacks scheduled inside it, sees the same store via <code>als.getStore()</code>. Concurrent requests each get their own. On Node.js 24, now the Active LTS line, it uses the <code>AsyncContextFrame</code> implementation by default, which <a href="https://nodejs.org/en/blog/release/v24.0.0" target="_blank" rel="noopener noreferrer">the release notes</a> describe as more efficient. The API is the same on Node 20, 22 and 24, so you don’t need to upgrade to use it (though the Node 24 LTS upgrade is worth planning anyway).</p>
<h2 id="how-to-add-an-asynclocalstorage-request-id-in-express">How to add an AsyncLocalStorage request ID in Express <a class="post-heading-anchor" href="#how-to-add-an-asynclocalstorage-request-id-in-express" aria-hidden="true">#</a></h2>
<h3 id="step-1-a-small-context-module">Step 1: a small context module <a class="post-heading-anchor" href="#step-1-a-small-context-module" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// context.js</span>
<span class="hljs-keyword">import</span> { <span class="hljs-title">AsyncLocalStorage</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:async_hooks'</span>;
<span class="hljs-keyword">import</span> { randomUUID } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:crypto'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> als = <span class="hljs-keyword">new</span> <span class="hljs-title">AsyncLocalStorage</span>();
<span class="hljs-keyword">const</span> <span class="hljs-variable">VALID_ID</span> = <span class="hljs-regexp">/^[A-Za-z0-9._-]{8,64}$/</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">newRequestId</span>(<span class="hljs-params">incoming</span>) {
  <span class="hljs-keyword">return</span> <span class="hljs-keyword">typeof</span> incoming === <span class="hljs-string">'string'</span> &amp;&amp; <span class="hljs-variable">VALID_ID</span>.<span class="hljs-title">test</span>(incoming)
    ? incoming
    : <span class="hljs-title">randomUUID</span>();
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title">getContext</span> = (<span class="hljs-params"></span>) =&gt; als.<span class="hljs-title">getStore</span>() ?? {};
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title">getRequestId</span> = (<span class="hljs-params"></span>) =&gt; <span class="hljs-title">getContext</span>().<span class="hljs-property">requestId</span>;
</code></pre></div>
<p>The regex is deliberately boring: UUIDs and most load balancer IDs pass, newlines and 10 KB strings don’t.</p>
<h3 id="step-2-one-middleware-at-the-top">Step 2: one middleware at the top <a class="post-heading-anchor" href="#step-2-one-middleware-at-the-top" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// app.js</span>
<span class="hljs-keyword">import</span> express <span class="hljs-keyword">from</span> <span class="hljs-string">'express'</span>;
<span class="hljs-keyword">import</span> { als, newRequestId } <span class="hljs-keyword">from</span> <span class="hljs-string">'./context.js'</span>;

<span class="hljs-keyword">const</span> app = <span class="hljs-title">express</span>();

app.<span class="hljs-title">use</span>(<span class="hljs-function">(<span class="hljs-params">req, res, next</span>) =&gt;</span> {
  <span class="hljs-keyword">const</span> requestId = <span class="hljs-title">newRequestId</span>(req.<span class="hljs-title">get</span>(<span class="hljs-string">'x-request-id'</span>));
  res.<span class="hljs-title">set</span>(<span class="hljs-string">'x-request-id'</span>, requestId);
  als.<span class="hljs-title">run</span>({ requestId }, next);
});

app.<span class="hljs-title">use</span>(express.<span class="hljs-title">json</span>());
app.<span class="hljs-title">get</span>(<span class="hljs-string">'/orders/:id'</span>, <span class="hljs-title">async</span> (req, res) =&gt; {
  res.<span class="hljs-title">json</span>(<span class="hljs-keyword">await</span> <span class="hljs-title">loadOrder</span>(req.<span class="hljs-property">params</span>.<span class="hljs-property">id</span>));
});

app.<span class="hljs-title">listen</span>(<span class="hljs-number">3000</span>);
</code></pre></div>
<p>Everything after <code>next</code> runs inside the context. <a href="https://www.apitraffic.io/express">Express</a> 5 forwards rejected promises from async handlers to your error middleware, so error log lines carry the ID too.</p>
<h3 id="step-3-make-the-logger-read-the-context">Step 3: make the logger read the context <a class="post-heading-anchor" href="#step-3-make-the-logger-read-the-context" aria-hidden="true">#</a></h3>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-comment">// logger.js</span>
<span class="hljs-keyword">import</span> pino <span class="hljs-keyword">from</span> <span class="hljs-string">'pino'</span>;
<span class="hljs-keyword">import</span> { getContext } <span class="hljs-keyword">from</span> <span class="hljs-string">'./context.js'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> logger = <span class="hljs-title">pino</span>({
  <span class="hljs-title">mixin</span>(<span class="hljs-params"></span>) {
    <span class="hljs-keyword">const</span> { requestId, userId } = <span class="hljs-title">getContext</span>();
    <span class="hljs-keyword">return</span> requestId ? { requestId, userId } : {};
  },
});
</code></pre></div>
<p>A repository function deep in the stack now logs with the ID attached, without knowing anything about HTTP:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">text</span></div><pre><code class="language-text hljs">{"level":30,"requestId":"5f0c2a1e-3b7d-4c55-9a51-0f4e2b8d6c19","orderId":"8812","ms":14,"msg":"order loaded"}
</code></pre></div>
<p>After authentication, <code>getContext().userId = user.id</code> puts the user on every later line. Mutating the store is fine; it belongs to this request only.</p>
<h2 id="request-ids-in-fastify-built-in-ids-plus-request-context">Request IDs in Fastify: built-in IDs plus request context <a class="post-heading-anchor" href="#request-ids-in-fastify-built-in-ids-plus-request-context" aria-hidden="true">#</a></h2>
<p><a href="https://www.apitraffic.io/fastify">Fastify</a> gives every request <code>request.id</code> and a child logger, <code>request.log</code>, that includes it as <code>reqId</code>. Two details in <a href="https://fastify.dev/docs/v5.6.x/Reference/Server.md" target="_blank" rel="noopener noreferrer">Fastify v5</a>:</p>
<ul>
<li><code>requestIdHeader</code> is <code>false</code> by default, so incoming <code>request-id</code> headers are ignored and <code>genReqId</code> is called. The default generator produces sequential IDs per process, which collide across instances.</li>
<li><code>request.log</code> only exists where <code>request</code> does. Your data layer usually doesn’t have it.</li>
</ul>
<p>Set <code>genReqId</code> yourself and use <code>@fastify/request-context</code>, which is built on <code>AsyncLocalStorage</code>, for code with no <code>request</code> in scope:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> <span class="hljs-title">Fastify</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'fastify'</span>;
<span class="hljs-keyword">import</span> { fastifyRequestContext, requestContext } <span class="hljs-keyword">from</span> <span class="hljs-string">'@fastify/request-context'</span>;
<span class="hljs-keyword">import</span> { newRequestId } <span class="hljs-keyword">from</span> <span class="hljs-string">'./context.js'</span>;

<span class="hljs-keyword">const</span> app = <span class="hljs-title">Fastify</span>({
  <span class="hljs-attr">logger</span>: <span class="hljs-literal">true</span>,
  <span class="hljs-attr">genReqId</span>: <span class="hljs-function">(<span class="hljs-params">req</span>) =&gt;</span> <span class="hljs-title">newRequestId</span>(req.<span class="hljs-property">headers</span>[<span class="hljs-string">'x-request-id'</span>]),
});
<span class="hljs-keyword">await</span> app.<span class="hljs-title">register</span>(fastifyRequestContext);

app.<span class="hljs-title">addHook</span>(<span class="hljs-string">'onRequest'</span>, <span class="hljs-title">async</span> (request, reply) =&gt; {
  request.<span class="hljs-property">requestContext</span>.<span class="hljs-title">set</span>(<span class="hljs-string">'requestId'</span>, request.<span class="hljs-property">id</span>);
  reply.<span class="hljs-title">header</span>(<span class="hljs-string">'x-request-id'</span>, request.<span class="hljs-property">id</span>);
});

<span class="hljs-comment">// anywhere in the call tree:</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title">currentRequestId</span> = (<span class="hljs-params"></span>) =&gt; requestContext.<span class="hljs-title">get</span>(<span class="hljs-string">'requestId'</span>);
</code></pre></div>
<h2 id="how-to-forward-the-request-id-to-other-services">How to forward the request ID to other services <a class="post-heading-anchor" href="#how-to-forward-the-request-id-to-other-services" aria-hidden="true">#</a></h2>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> { getRequestId } <span class="hljs-keyword">from</span> <span class="hljs-string">'./context.js'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title">apiFetch</span>(<span class="hljs-params">url, init = {}</span>) {
  <span class="hljs-keyword">const</span> headers = <span class="hljs-keyword">new</span> <span class="hljs-title">Headers</span>(init.<span class="hljs-property">headers</span>);
  <span class="hljs-keyword">const</span> id = <span class="hljs-title">getRequestId</span>();
  <span class="hljs-keyword">if</span> (id &amp;&amp; !headers.<span class="hljs-title">has</span>(<span class="hljs-string">'x-request-id'</span>)) headers.<span class="hljs-title">set</span>(<span class="hljs-string">'x-request-id'</span>, id);
  <span class="hljs-keyword">return</span> <span class="hljs-title">fetch</span>(url, { ...init, headers });
}
</code></pre></div>
<p>The receiving service runs the same middleware and its logs line up with yours. A 1-person team with one service can skip this until a second service exists; a 10-person team with several services should do it on day one.</p>
<h2 id="why-asynclocalstorage-loses-context">Why AsyncLocalStorage loses context <a class="post-heading-anchor" href="#why-asynclocalstorage-loses-context" aria-hidden="true">#</a></h2>
<ul>
<li><strong>Library callback queues.</strong> Some older libraries queue callbacks and invoke them from a different async context. If <code>getRequestId()</code> returns <code>undefined</code> in a callback, wrap it with <code>AsyncResource.bind(fn)</code> from <code>node:async_hooks</code>.</li>
<li><strong>Listeners registered at startup</strong> run in the boot context. Put the ID in the event payload.</li>
<li><strong><code>enterWith()</code></strong> sets the store with no clear end. Prefer <code>run()</code>.</li>
<li><strong>Background jobs</strong> run later, often in another process. Copy the ID into the job payload.</li>
</ul>
<p>A test catches regressions:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs"><span class="hljs-keyword">import</span> { test } <span class="hljs-keyword">from</span> <span class="hljs-string">'node:test'</span>;
<span class="hljs-keyword">import</span> assert <span class="hljs-keyword">from</span> <span class="hljs-string">'node:assert/strict'</span>;
<span class="hljs-keyword">import</span> { als, getRequestId } <span class="hljs-keyword">from</span> <span class="hljs-string">'./context.js'</span>;

<span class="hljs-title">test</span>(<span class="hljs-string">'request id survives awaits and timers'</span>, <span class="hljs-title">async</span> () =&gt; {
  <span class="hljs-keyword">await</span> als.<span class="hljs-title">run</span>({ <span class="hljs-attr">requestId</span>: <span class="hljs-string">'test-req-0001'</span> }, <span class="hljs-title">async</span> () =&gt; {
    <span class="hljs-keyword">await</span> <span class="hljs-keyword">new</span> <span class="hljs-title">Promise</span>(<span class="hljs-function">(<span class="hljs-params">r</span>) =&gt;</span> <span class="hljs-built_in">setTimeout</span>(r, <span class="hljs-number">5</span>));
    assert.<span class="hljs-title">equal</span>(<span class="hljs-title">getRequestId</span>(), <span class="hljs-string">'test-req-0001'</span>);
  });
});
</code></pre></div>
<h2 id="if-you-only-do-one-thing">If you only do one thing <a class="post-heading-anchor" href="#if-you-only-do-one-thing" aria-hidden="true">#</a></h2>
<p>Add the Step 2 middleware and the Pino <code>mixin</code>, and return <code>x-request-id</code> on every response, errors included. The rest (forwarding, job payloads, Fastify context) can follow when you need it.</p>
<h2 id="faq">FAQ <a class="post-heading-anchor" href="#faq" aria-hidden="true">#</a></h2>
<h3 id="is-asynclocalstorage-slow">Is AsyncLocalStorage slow? <a class="post-heading-anchor" href="#is-asynclocalstorage-slow" aria-hidden="true">#</a></h3>
<p>It adds some overhead per async operation, and Node 24 switched to a more efficient implementation by default. For a typical API doing database and network I/O, request handling time is dominated by that I/O, not by context tracking.</p>
<h3 id="should-i-accept-a-request-id-from-the-client">Should I accept a request ID from the client? <a class="post-heading-anchor" href="#should-i-accept-a-request-id-from-the-client" aria-hidden="true">#</a></h3>
<p>Accept it from a proxy or load balancer you control, and validate the format either way. Never write an unvalidated header straight into logs.</p>
<h3 id="request-id-vs-trace-id-do-i-need-both">Request ID vs trace ID: do I need both? <a class="post-heading-anchor" href="#request-id-vs-trace-id-do-i-need-both" aria-hidden="true">#</a></h3>
<p>A trace ID from OpenTelemetry’s <a href="/blog/w3c-trace-context-node"><code>traceparent</code> header</a> is built for tracing tools. A request ID is short, human-quotable and safe to show a customer. Many teams keep both.</p>
<h2 id="doing-this-with-apitraffic">Doing this with ApiTraffic <a class="post-heading-anchor" href="#doing-this-with-apitraffic" aria-hidden="true">#</a></h2>
<p>If you’d rather not stitch request logs together yourself, ApiTraffic records every inbound request and response from one middleware (method, path, status, latency, headers, bodies) and <a href="https://www.apitraffic.io/docs/outbound-requests">logs outbound requests</a> too. <a href="https://www.apitraffic.io/docs/tags">Tag the request ID and user</a> so you can find them later:</p>
<div class="post-code"><div class="post-code-head"><span class="post-code-label">js</span></div><pre><code class="language-js hljs">app.<span class="hljs-title">use</span>(express.<span class="hljs-title">json</span>());
app.<span class="hljs-title">use</span>(apitraffic.<span class="hljs-title">express</span>());
app.<span class="hljs-title">use</span>(<span class="hljs-function">(<span class="hljs-params">req, res, next</span>) =&gt;</span> {
  apitraffic.<span class="hljs-title">tag</span>(<span class="hljs-string">'requestId'</span>, res.<span class="hljs-title">get</span>(<span class="hljs-string">'x-request-id'</span>));
  <span class="hljs-keyword">if</span> (req.<span class="hljs-property">user</span>) apitraffic.<span class="hljs-title">tag</span>(<span class="hljs-string">'userId'</span>, <span class="hljs-title">String</span>(req.<span class="hljs-property">user</span>.<span class="hljs-property">id</span>));
  <span class="hljs-title">next</span>();
});
</code></pre></div>
<p>Then search for what a customer reported, for example <code>tag:userId == "1042" AND status_code &gt;= 400</code>. With the <a href="https://www.apitraffic.io/docs/apm/setup">APM preload</a> (<code>node -r @apitraffic/node/register ./server.js</code>), each request’s waterfall shows the queries and outbound calls it made.</p>
<p>Start free and see every request your API handles: <a href="https://apitraffic.io/?utm_source=blog&amp;utm_medium=content&amp;utm_campaign=always-on&amp;utm_content=bf-20251124-asynclocalstorage-request-id">apitraffic.io</a></p>
<p>Sources: <a href="https://nodejs.org/en/blog/release/v24.0.0" target="_blank" rel="noopener noreferrer">Node.js v24.0.0 release notes, May 6, 2025</a>; <a href="https://nodejs.org/api/async_context.html" target="_blank" rel="noopener noreferrer">Node.js AsyncLocalStorage docs</a>; <a href="https://fastify.dev/docs/v5.6.x/Reference/Server.md" target="_blank" rel="noopener noreferrer">Fastify v5 Server reference, requestIdHeader and genReqId</a></p>
]]></content:encoded>
    </item>
  </channel>
</rss>
