<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Libme</title>
    <description>The latest articles on DEV Community by Libme (@libme).</description>
    <link>https://dev.to/libme</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4062668%2F1762b3d3-a3e8-4856-a46b-270e29821fed.png</url>
      <title>DEV Community: Libme</title>
      <link>https://dev.to/libme</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/libme"/>
    <language>en</language>
    <item>
      <title>Webhook Signature Verification Fails: Your Framework Already Parsed the Body</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sun, 13 Sep 2026 23:58:29 +0000</pubDate>
      <link>https://dev.to/libme/webhook-signature-verification-fails-your-framework-already-parsed-the-body-2g7g</link>
      <guid>https://dev.to/libme/webhook-signature-verification-fails-your-framework-already-parsed-the-body-2g7g</guid>
      <description>&lt;p&gt;If a webhook signature check fails and you are sure the secret is right, the body your code is hashing is almost never the body the sender hashed. Something between the socket and your handler parsed the JSON and re-serialized it, and HMAC does not forgive a single changed byte. The fix is always the same shape: get the raw request bytes before any body parser runs, hash those, and compare with a constant-time function.&lt;/p&gt;

&lt;p&gt;This post walks through the failure as it shows up with Stripe and GitHub, why the obvious workaround (&lt;code&gt;JSON.stringify(req.body)&lt;/code&gt;) fails in ways that look random, and the raw-body recipe for each framework I have had to do this in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the error actually look like?
&lt;/h2&gt;

&lt;p&gt;With stripe-node the message is explicit, which is why it ends up in so many search queries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Webhook Error: No signatures found matching the expected signature for payload.
Are you passing the raw request body you received from Stripe?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;GitHub gives you nothing that helpful. Your own check just returns false, the delivery shows a 401 in the repository's webhook settings, and you start doubting the secret. A quick way to rule the secret out: copy the raw payload from GitHub's "Recent Deliveries" tab, HMAC it locally with the secret, and compare with the &lt;code&gt;X-Hub-Signature-256&lt;/code&gt; header shown on the same page. If that matches and your server still rejects it, the server is hashing different bytes.&lt;/p&gt;

&lt;p&gt;Both senders sign the exact byte sequence they put on the wire. Stripe signs &lt;code&gt;${timestamp}.${rawBody}&lt;/code&gt; with HMAC-SHA256 and sends &lt;code&gt;t=...,v1=...&lt;/code&gt; in the &lt;code&gt;Stripe-Signature&lt;/code&gt; header. GitHub signs the raw body and sends &lt;code&gt;sha256=&amp;lt;hex&amp;gt;&lt;/code&gt; in &lt;code&gt;X-Hub-Signature-256&lt;/code&gt;. Neither signs "the JSON document" in any abstract sense.&lt;/p&gt;

&lt;p&gt;The signature is over bytes, not over the data those bytes encode, so any step that decodes and re-encodes the body invalidates it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does re-serializing the parsed body not work?
&lt;/h2&gt;

&lt;p&gt;The first thing most people try is &lt;code&gt;JSON.stringify(req.body)&lt;/code&gt; and hoping it round-trips. It sometimes does, which is the worst outcome, because it passes in tests and fails in production on the first payload that contains a float, a non-ASCII character, or whitespace the sender happened to emit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"amount": 1000, "note": "caf&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;u00e9", "ratio": 1.0}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// '{"amount":1000,"note":"café","ratio":1}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three differences from one line: the spaces after colons are gone, &lt;code&gt;é&lt;/code&gt; became a literal &lt;code&gt;é&lt;/code&gt;, and &lt;code&gt;1.0&lt;/code&gt; became &lt;code&gt;1&lt;/code&gt;. Any one of them changes the hash. Python's &lt;code&gt;json.dumps&lt;/code&gt; has its own defaults (spaces after separators, &lt;code&gt;ensure_ascii=True&lt;/code&gt;) that differ from JavaScript's, so a Node sender and a Python receiver will disagree even on the "same" object.&lt;/p&gt;

&lt;p&gt;There is no serializer setting that reliably reproduces another system's output; the only byte-exact copy of the payload is the one you received.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I get the raw body in each framework?
&lt;/h2&gt;

&lt;p&gt;The pattern is the same everywhere: read the body stream once, as bytes, before anything else consumes it. Where it goes wrong is framework-specific.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Raw body access&lt;/th&gt;
&lt;th&gt;What silently breaks it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Express&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;express.raw({ type: "application/json" })&lt;/code&gt; on the webhook route only&lt;/td&gt;
&lt;td&gt;Registering &lt;code&gt;app.use(express.json())&lt;/code&gt; before the webhook route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js App Router&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;await request.text()&lt;/code&gt; in the route handler&lt;/td&gt;
&lt;td&gt;Calling &lt;code&gt;request.json()&lt;/code&gt; first; the stream can be read once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js Pages Router&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;export const config = { api: { bodyParser: false } }&lt;/code&gt; and read the stream&lt;/td&gt;
&lt;td&gt;Forgetting the config export; the default parser runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FastAPI / Starlette&lt;/td&gt;
&lt;td&gt;&lt;code&gt;await request.body()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nothing serious; Starlette caches the body, so this is the easy one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flask&lt;/td&gt;
&lt;td&gt;&lt;code&gt;request.get_data()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Using &lt;code&gt;request.data&lt;/code&gt;, which is empty for form mimetypes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Lambda (API Gateway)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;event.body&lt;/code&gt;, decode with base64 when &lt;code&gt;event.isBase64Encoded&lt;/code&gt; is true&lt;/td&gt;
&lt;td&gt;Hashing the base64 string instead of the decoded bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Express deserves the full example because route order is the trap. The raw parser has to be attached to the webhook route and the JSON parser has to come after it, or be scoped so it never sees that path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// Webhook route first, with a raw parser. req.body is a Buffer here.&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/stripe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Webhook Error: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// handle event.type here&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Everything else gets parsed JSON.&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you cannot reorder middleware (a shared app factory, a framework wrapper you do not control), the &lt;code&gt;verify&lt;/code&gt; hook on &lt;code&gt;express.json&lt;/code&gt; is the escape hatch. It runs with the original buffer before parsing and lets you stash it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then verify against &lt;code&gt;req.rawBody&lt;/code&gt;. This works for every route at once, at the cost of holding a second copy of each body in memory, which matters only if your JSON bodies are large.&lt;/p&gt;

&lt;p&gt;For a GitHub-style HMAC where you write the check yourself, the comparison has to be constant-time and length-checked, because &lt;code&gt;timingSafeEqual&lt;/code&gt; throws on mismatched lengths rather than returning false:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timingSafeEqual&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyGithubSignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headerValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;headerValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headerValue&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the Python equivalent, where &lt;code&gt;hmac.compare_digest&lt;/code&gt; already handles the constant-time part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GITHUB_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/webhooks/github&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;github_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# bytes, exactly as received
&lt;/span&gt;    &lt;span class="n"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-hub-signature-256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha256=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bad signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# safe to parse now
&lt;/span&gt;    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whichever framework you use, the webhook route should be the one place in the app where the body parser is explicitly bypassed, and the code should say so in a comment for the next person who "fixes" the middleware order.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else changes the bytes between sender and handler?
&lt;/h2&gt;

&lt;p&gt;Once the body parser is out of the way, the remaining causes are infrastructure. Two I have actually hit:&lt;/p&gt;

&lt;p&gt;An API gateway with a body mapping template or JSON transformation enabled. Anything that reformats request bodies for downstream services is re-serializing them. The fix is to exempt the webhook path from the transformation, not to try to reverse it.&lt;/p&gt;

&lt;p&gt;Lambda behind API Gateway with binary media types configured. The body arrives base64-encoded and &lt;code&gt;isBase64Encoded&lt;/code&gt; is true; hashing &lt;code&gt;event.body&lt;/code&gt; directly hashes the base64 text. Decode to a Buffer first, then hash.&lt;/p&gt;

&lt;p&gt;Ordinary reverse proxies and CDNs do not touch bodies, so nginx, Caddy, or Cloudflare in front of the app are not suspects here. Compression is also not a suspect for inbound webhooks, because the sender does not gzip the request body and the proxy decompresses before your handler sees it anyway.&lt;/p&gt;

&lt;p&gt;If the parser is bypassed and verification still fails, look for a component that advertises "transformation" or "mapping" of request bodies; it is doing exactly what it says.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I test this without waiting for real events?
&lt;/h2&gt;

&lt;p&gt;For Stripe, the CLI's &lt;code&gt;stripe listen --forward-to localhost:3000/webhooks/stripe&lt;/code&gt; gives you a local signing secret and replays real event shapes, which is the fastest loop I know for this class of bug; its limitation is that it only speaks Stripe. For any other sender, expose the local port with ngrok or Cloudflare Tunnel and use the provider's "redeliver" button. If you want a hop that records every delivery and lets you replay it against a rebuilt handler, Hookdeck is the one that sits in front of your endpoint and keeps the raw payload and headers, at the cost of adding an external dependency on the path that has to be up when the payment provider retries.&lt;/p&gt;

&lt;p&gt;Whatever you use, write one unit test that feeds a hard-coded raw payload and a signature you computed yourself, so a future middleware change fails in CI rather than on a customer's checkout.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does Stripe say "No signatures found matching the expected signature for payload" in Express?&lt;/strong&gt;&lt;br&gt;
Because &lt;code&gt;express.json()&lt;/code&gt; parsed the body before &lt;code&gt;constructEvent&lt;/code&gt; ran, so the handler hashed a re-serialized object instead of the bytes Stripe signed. Attach &lt;code&gt;express.raw({ type: "application/json" })&lt;/code&gt; to the webhook route and register it before the JSON parser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I verify a webhook signature from a parsed JSON body?&lt;/strong&gt;&lt;br&gt;
No. Serializers normalize whitespace, unicode escapes, and number formatting, so the output is not byte-identical to what the sender signed. You need the original request bytes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I compare the signature with &lt;code&gt;===&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
No. Use &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; in Node or &lt;code&gt;hmac.compare_digest&lt;/code&gt; in Python, and check lengths first in Node because &lt;code&gt;timingSafeEqual&lt;/code&gt; throws on different-length inputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If you own the app, put the raw-body parser on the webhook route and the JSON parser after it, and treat that ordering as load-bearing. If you cannot control middleware order, use the parser's &lt;code&gt;verify&lt;/code&gt; hook to capture the buffer for every request. If the parser is already out of the picture and signatures still fail, the culprit is a gateway or serverless layer re-encoding the body, not your secret. In every case, lock the behavior in with a test that uses a fixed payload and a precomputed signature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/admission-control-for-self-hosted-llms-rejecting-requests-before-the-kv-cache-ooms-you-35b8"&gt;Admission Control for Self-Hosted LLMs: Rejecting Requests Before the KV Cache OOMs You&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/why-your-p99-looks-fine-while-users-complain-averaged-percentiles-and-histogram-buckets-eej"&gt;Why Your p99 Looks Fine While Users Complain: Averaged Percentiles and Histogram Buckets&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/railway-vs-render-vs-flyio-deploying-a-hobby-app-without-a-devops-team-10d3"&gt;Railway vs Render vs Fly.io: Deploying a Hobby App Without a DevOps Team&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>api</category>
      <category>backend</category>
      <category>security</category>
    </item>
    <item>
      <title>Playwright vs Cypress When Your E2E Tests Pass Locally but Fail in CI</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sun, 13 Sep 2026 01:01:35 +0000</pubDate>
      <link>https://dev.to/libme/playwright-vs-cypress-when-your-e2e-tests-pass-locally-but-fail-in-ci-4pba</link>
      <guid>https://dev.to/libme/playwright-vs-cypress-when-your-e2e-tests-pass-locally-but-fail-in-ci-4pba</guid>
      <description>&lt;p&gt;Tests that pass on your laptop and fail in CI are almost never a tool bug; they are timing assumptions that a slower, colder CI runner exposes. Both Playwright and Cypress can be made stable, but they get there differently: Playwright gives you built-in retries, per-test traces, and free sharding across runners; Cypress gives you retry-able assertions inside the browser and a very good interactive debugger, with parallelization living behind its paid cloud service. If you are choosing today, Playwright is the lower-friction path for a CI-first team, and Cypress is the stronger pick when the people writing tests are mostly debugging them by watching the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does "flaky in CI only" actually look like?
&lt;/h2&gt;

&lt;p&gt;The two failure texts I see most often are worth recognizing on sight. In Playwright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: locator.click: Test timeout of 30000ms exceeded.
Call log:
  - waiting for getByRole('button', { name: 'Save' })
  -   locator resolved to &amp;lt;button disabled …&amp;gt;Save&amp;lt;/button&amp;gt;
  - attempting click action
  -   waiting for element to be visible, enabled and stable
  -   element is not enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Cypress:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CypressError: Timed out retrying after 4000ms: Expected to find element: `[data-test=save-button]`, but never found it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Locally these never fire because your dev server responds in tens of milliseconds and the button becomes enabled before the test even looks for it. In CI, the app boots cold, the runner has two vCPUs shared with the browser, and the same request takes long enough that the test's assumption ("by the time I click, the form is ready") stops holding. The tool is telling the truth: the element genuinely was not ready inside its default budget.&lt;/p&gt;

&lt;p&gt;The dead ends people try first are the same in both tools: adding &lt;code&gt;waitForTimeout(2000)&lt;/code&gt; or &lt;code&gt;cy.wait(2000)&lt;/code&gt;, then bumping the number when it fails again. That trades a flaky test for a slow one and still fails on the one run where CI is slower than your padding. The fix is always to wait for the &lt;em&gt;condition&lt;/em&gt; the click depends on, and both tools have a proper primitive for that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a CI-only failure is a hidden &lt;code&gt;sleep&lt;/code&gt; in your test logic, and the fix is naming the condition you were implicitly assuming.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do Playwright and Cypress wait differently?
&lt;/h2&gt;

&lt;p&gt;Playwright auto-waits before every action: &lt;code&gt;click()&lt;/code&gt; will not fire until the element is attached, visible, stable, enabled, and receiving pointer events. Assertions like &lt;code&gt;expect(locator).toBeEnabled()&lt;/code&gt; retry until the expect timeout. What it does &lt;em&gt;not&lt;/em&gt; do is wait for arbitrary application state, so if your button is enabled before its data is loaded, you have to say what you mean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;saves the profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;profileLoaded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/settings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;profileLoaded&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Display name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Jay&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Save&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Saved&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cypress runs inside the browser and makes commands retry-able rather than actions auto-waiting. &lt;code&gt;cy.get()&lt;/code&gt; and the assertion chained to it keep retrying until the default command timeout, and the idiomatic wait for network is an intercept alias:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;saves the profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;intercept&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;visit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/settings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findByLabelText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Display name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Jay&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-test=save-button]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;should&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;be.enabled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Saved&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;should&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;be.visible&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are correct. The difference that matters in CI is what happens when they are &lt;em&gt;not&lt;/em&gt; correct. Playwright's error above came with a call log that said "element is not enabled", which points straight at the cause. Cypress's error tells you the element was never found, and to see why you open the run's screenshot or video, or use Test Replay if you are on Cypress Cloud. In my experience the call log alone resolves a good share of CI-only failures without downloading anything.&lt;/p&gt;

&lt;p&gt;One Cypress-specific trap: because commands are queued and run asynchronously, mixing them with plain &lt;code&gt;await&lt;/code&gt; or &lt;code&gt;if&lt;/code&gt; on a value you have not yet yielded produces tests that pass locally by luck. Playwright's plain &lt;code&gt;async/await&lt;/code&gt; model has fewer of these footguns, but it has its own: forgetting an &lt;code&gt;await&lt;/code&gt; on an action silently races the next line, and TypeScript will not always warn you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: Playwright waits on the action and tells you which precondition failed; Cypress waits on the query and tells you it eventually gave up.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What do retries and artifacts cost you in each tool?
&lt;/h2&gt;

&lt;p&gt;Retries are a diagnostic tool, not a fix, and both tools support them in a way that keeps the first-run failure visible.&lt;/p&gt;

&lt;p&gt;Playwright's config lets you retry only in CI and capture a full trace only when a retry happens, which keeps artifact size small on green runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// playwright.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CI&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CI&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fullyParallel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CI&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blob&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;trace&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;on-first-retry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;only-on-failure&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;retain-on-failure&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A test that passes on retry is reported as "flaky" rather than "passed", so you can grep the report for tests that are quietly degrading. The trace file opens in the trace viewer with a DOM snapshot at every step, network log, and console output; it is the single feature that made me stop reproducing CI failures locally.&lt;/p&gt;

&lt;p&gt;Cypress configures retries per mode, and since version 13 (as of my last check) does not record video by default, so you opt in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// cypress.config.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cypress&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;runMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;openMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;e2e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The honest limitation on the Cypress side is that the richest artifact, Test Replay, requires recording to Cypress Cloud. Without it you are working from screenshots and video, which show &lt;em&gt;what&lt;/em&gt; happened but not the network timeline. On the Playwright side, the honest limitation is that traces on a large suite with &lt;code&gt;trace: 'on'&lt;/code&gt; get big fast and slow uploads down, which is why the on-first-retry setting is the one to keep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: turn on retries in CI only, and treat "flaky" in the report as a bug queue, not a pass.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When does parallelization change the decision?
&lt;/h2&gt;

&lt;p&gt;This is where the tools diverge most sharply. Playwright shards a suite across CI machines with a flag and merges the reports afterward, with no service involved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/e2e.yml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;fail-fast&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;shard&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;2&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;3&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;4&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx playwright test --shard=${{ matrix.shard }}/4&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/upload-artifact@v4&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;blob-report-${{ matrix.shard }}&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;blob-report&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A follow-up job downloads the blobs and runs &lt;code&gt;npx playwright merge-reports --reporter html ./all-blob-reports&lt;/code&gt;. Test isolation comes from a fresh browser context per test, which is cheap, so &lt;code&gt;fullyParallel&lt;/code&gt; is usually safe unless tests share a database row.&lt;/p&gt;

&lt;p&gt;Cypress runs one spec at a time per machine. Splitting specs across machines is a Cypress Cloud feature (load-balanced by historical duration), and the open-source alternatives are third-party orchestrators you host yourself. For a small suite this does not matter. For a suite that has crossed the point where a single runner takes fifteen minutes, it is often the deciding factor, because you are choosing between a recurring bill and operating another service.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Playwright&lt;/th&gt;
&lt;th&gt;Cypress&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Waiting model&lt;/td&gt;
&lt;td&gt;Auto-wait on actions, retrying &lt;code&gt;expect&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Retry-able commands and chained assertions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default failure detail&lt;/td&gt;
&lt;td&gt;Step call log in the error&lt;/td&gt;
&lt;td&gt;Screenshot; video and Test Replay optional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retries&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;retries&lt;/code&gt; in config, per-project&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;retries&lt;/code&gt; per run/open mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trace/time-travel&lt;/td&gt;
&lt;td&gt;Trace viewer, local files&lt;/td&gt;
&lt;td&gt;Time-travel in interactive runner; Test Replay via Cloud&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parallel across machines&lt;/td&gt;
&lt;td&gt;Built-in &lt;code&gt;--shard&lt;/code&gt;, free&lt;/td&gt;
&lt;td&gt;Cypress Cloud or self-hosted orchestrator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-tab / multi-origin&lt;/td&gt;
&lt;td&gt;Native contexts and pages&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cy.origin()&lt;/code&gt; for cross-origin; no true multi-tab&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browsers&lt;/td&gt;
&lt;td&gt;Chromium, Firefox, WebKit&lt;/td&gt;
&lt;td&gt;Chrome family, Firefox, WebKit experimental&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging locally&lt;/td&gt;
&lt;td&gt;UI mode, &lt;code&gt;--debug&lt;/code&gt;, trace viewer&lt;/td&gt;
&lt;td&gt;Interactive runner with DOM snapshots per command&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your team's bottleneck is CI wall-clock time and you want sharding without a vendor, Playwright is the one that gives you free cross-machine parallelism with a single flag. If your team's bottleneck is people understanding why a test failed while watching it run, Cypress is the one whose interactive runner makes every command's before-and-after DOM state clickable without extra setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the moment your suite outgrows one runner, the parallelization model stops being a feature comparison and becomes a budget line.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why do my Playwright tests pass locally but fail in CI?&lt;/strong&gt;&lt;br&gt;
Because CI is slower and colder, so elements that were already ready on your machine are still loading when the test acts. Replace fixed waits with &lt;code&gt;expect(locator)&lt;/code&gt; assertions or &lt;code&gt;page.waitForResponse&lt;/code&gt; for the specific request the action depends on, and enable &lt;code&gt;trace: 'on-first-retry'&lt;/code&gt; to see the exact step that stalled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I make Cypress tests less flaky in CI?&lt;/strong&gt;&lt;br&gt;
Set &lt;code&gt;retries: { runMode: 2, openMode: 0 }&lt;/code&gt;, wait on intercepted requests with &lt;code&gt;cy.intercept().as()&lt;/code&gt; and &lt;code&gt;cy.wait('@alias')&lt;/code&gt; instead of &lt;code&gt;cy.wait(ms)&lt;/code&gt;, and chain &lt;code&gt;.should('be.enabled')&lt;/code&gt; before clicking. Treat any test that only passes on retry as a bug to fix, not a green result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Playwright or Cypress better for CI?&lt;/strong&gt;&lt;br&gt;
Playwright has the lower CI cost because retries, traces, and cross-machine sharding are built in and free. Cypress is competitive on a single runner but its parallelization and richest failure artifacts depend on Cypress Cloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If you are starting a new suite and it will run primarily in CI, pick Playwright: auto-waiting reduces the class of timing bugs, the trace viewer answers "why did this fail" without a local repro, and sharding is a flag rather than a subscription. If your team already has a Cypress suite and mostly debugs by watching the interactive runner, stay and fix the flake with intercept aliases and CI-only retries; the tool is not the problem. Migrate only when a single runner's wall-clock time becomes the thing blocking merges, because that is the one gap the free tier of Cypress does not close.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/when-is-github-copilot-actually-worth-the-subscription-a-break-even-analysis-5c7p"&gt;When Is GitHub Copilot Actually Worth the Subscription? A Break-Even Analysis&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/the-solo-developers-2026-stack-whats-worth-paying-for-and-what-to-self-host-26g7"&gt;The Solo Developer's 2026 Stack: What's Worth Paying For and What to Self-Host&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/feature-flags-when-a-managed-service-beats-a-config-table-you-own-4f20"&gt;Feature Flags: When a Managed Service Beats a Config Table You Own&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>testing</category>
      <category>cicd</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your Nightly Job Ran Twice on the DST Switch: Making Scheduled Jobs Timezone-Safe</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Fri, 11 Sep 2026 00:26:28 +0000</pubDate>
      <link>https://dev.to/libme/your-nightly-job-ran-twice-on-the-dst-switch-making-scheduled-jobs-timezone-safe-32hn</link>
      <guid>https://dev.to/libme/your-nightly-job-ran-twice-on-the-dst-switch-making-scheduled-jobs-timezone-safe-32hn</guid>
      <description>&lt;p&gt;If your scheduler stores a wall-clock time in a local timezone, a job at 01:30 runs &lt;strong&gt;twice&lt;/strong&gt; on the fall-back date and a job at 02:30 runs &lt;strong&gt;zero times&lt;/strong&gt; on the spring-forward date. Setting &lt;code&gt;TZ=America/New_York&lt;/code&gt; on your cron does not fix this — it is what causes it. The fix is to separate &lt;em&gt;when the trigger fires&lt;/em&gt; from &lt;em&gt;what logical period the job covers&lt;/em&gt;, and to make the job claim its logical date before doing work.&lt;/p&gt;

&lt;p&gt;I lost a morning to this: a billing summary that emailed customers twice on November 1, once at 01:30 EDT and once at 01:30 EST, 60 minutes apart, from a scheduler nobody had touched in months.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens at the transition
&lt;/h2&gt;

&lt;p&gt;Two local wall-clock times are broken, not one. In &lt;code&gt;America/New_York&lt;/code&gt;, DST for 2026 starts Sunday March 8 and ends Sunday November 1 (EU zones switch on different dates — March 29 and October 25 in 2026, still observed as of mid-2026).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fall back:&lt;/strong&gt; the clock goes 01:59 → 01:00. Every wall-clock time in the 01:00–01:59 range happens twice, one hour apart in real time. A cron entry for &lt;code&gt;30 1 * * *&lt;/code&gt; fires on both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring forward:&lt;/strong&gt; the clock goes 01:59 → 03:00. Wall-clock times in 02:00–02:59 never occur. A cron entry for &lt;code&gt;30 2 * * *&lt;/code&gt; fires once, twice, or not at all depending on which scheduler you use — none of them agree.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Python's &lt;code&gt;zoneinfo&lt;/code&gt; makes the ambiguity visible through PEP 495's &lt;code&gt;fold&lt;/code&gt; flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;zoneinfo&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ZoneInfo&lt;/span&gt;

&lt;span class="n"&gt;ny&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ZoneInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;America/New_York&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;first&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ny&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# fold=0
&lt;/span&gt;&lt;span class="n"&gt;second&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ny&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# 2026-11-01 05:30:00+00:00
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# 2026-11-01 06:30:00+00:00
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="c1"&gt;# True  &amp;lt;- the trap
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are two different instants an hour apart, and Python reports them as equal, because same-zone comparison ignores &lt;code&gt;fold&lt;/code&gt; by design. Any dedupe check written as &lt;code&gt;if run_at == last_run_at: skip&lt;/code&gt; silently passes through the duplicate. Convert to UTC &lt;em&gt;before&lt;/em&gt; comparing and the check works.&lt;/p&gt;

&lt;p&gt;The nonexistent time is quieter. &lt;code&gt;datetime(2026, 3, 8, 2, 30, tzinfo=ny)&lt;/code&gt; constructs happily with a &lt;code&gt;-05:00&lt;/code&gt; offset, but round-tripping it through UTC lands on 03:30 EDT — the value you built never existed on any clock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a local wall-clock time is not a unique instant twice a year, and your language will not raise an error when you treat it like one.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "just set the scheduler's timezone" isn't enough
&lt;/h2&gt;

&lt;p&gt;Scheduler-native timezone support is real and worth using, but it only decides &lt;em&gt;when to fire&lt;/em&gt;. It gives you nothing about the far more expensive question: did this job already run for this business day?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Local timezone support&lt;/th&gt;
&lt;th&gt;Behavior you still have to handle&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Linux &lt;code&gt;cron&lt;/code&gt; (&lt;code&gt;CRON_TZ=&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Ambiguous hour can fire twice; gap behavior varies by cron implementation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;systemd timers&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;OnCalendar&lt;/code&gt; + &lt;code&gt;Timezone=&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Persistent=true&lt;/code&gt; fires missed runs at boot — a second execution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes &lt;code&gt;CronJob&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes, &lt;code&gt;spec.timeZone&lt;/code&gt; (stable since 1.27)&lt;/td&gt;
&lt;td&gt;Docs state a schedule may create two Jobs or none for one slot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Actions &lt;code&gt;schedule&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No — UTC only&lt;/td&gt;
&lt;td&gt;Delivery is best-effort and can be delayed under load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon EventBridge Scheduler&lt;/td&gt;
&lt;td&gt;Yes, IANA names&lt;/td&gt;
&lt;td&gt;Retries and flexible time windows can re-invoke the target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Cloud Scheduler&lt;/td&gt;
&lt;td&gt;Yes, &lt;code&gt;timeZone&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;At-least-once delivery; the target must be idempotent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice the pattern in the right column: every one of these is &lt;em&gt;at-least-once&lt;/em&gt;. DST is not a special case, it is just the twice-a-year reminder that your job needs to survive being invoked more than once for the same period.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: scheduler timezone settings fix the wall clock, not the duplicate; none of these platforms promise exactly-once.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-clock rule
&lt;/h2&gt;

&lt;p&gt;Every scheduled job has two clocks, and mixing them is the actual bug:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The trigger clock&lt;/strong&gt; — a real instant, always UTC, best-effort, may fire early, late, or twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The logical clock&lt;/strong&gt; — the business period the run covers ("the report for 2026-11-01"), which advances exactly once per period no matter what the trigger does.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once you name the logical date, the fix writes itself. Tick frequently in UTC, compute whether the local target time has passed for the current logical date, then claim that date before working:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;zoneinfo&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ZoneInfo&lt;/span&gt;

&lt;span class="n"&gt;UTC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;local_target_utc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wall&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ZoneInfo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;UTC instant for `wall` clock time on `day` in `tz`.
    Nonexistent (spring forward) -&amp;gt; the instant the clock jumps to.
    Ambiguous (fall back) -&amp;gt; the first, earlier occurrence.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;combine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wall&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resolved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;wall&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="c1"&gt;# the wall time was skipped
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;first&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_due&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now_utc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ZoneInfo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wall&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;date&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;logical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now_utc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;logical&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;now_utc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nf"&gt;local_target_utc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wall&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tz&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verified against the 2026 transitions in &lt;code&gt;America/New_York&lt;/code&gt;: a 02:30 target on March 8 resolves to 07:30 UTC (03:30 EDT, the moment the clock lands), and a 01:30 target on November 1 resolves to 05:30 UTC — the &lt;em&gt;first&lt;/em&gt; 01:30, deterministically, not whichever one the scheduler happens to hit.&lt;/p&gt;

&lt;p&gt;The claim is a unique constraint, not a lock you have to think about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;job_runs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;job_name&lt;/span&gt;     &lt;span class="nb"&gt;text&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;logical_date&lt;/span&gt; &lt;span class="nb"&gt;date&lt;/span&gt;        &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;started_at&lt;/span&gt;   &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;job_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logical_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insert into job_runs (job_name, logical_date) values (%s, %s) &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on conflict do nothing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing_summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logical&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;  &lt;span class="c1"&gt;# already ran for this logical date
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this every 15 minutes in UTC from any scheduler on the table above and the DST question disappears: extra triggers hit the conflict and return, a missed trigger is picked up by the next tick, and a retried invocation is free. One caveat worth handling on first deploy — a brand-new job whose target time has already passed today will fire immediately, so seed the table with today's row if you don't want that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a unique key on (job, logical_date) turns at-least-once delivery into exactly-once work, and DST stops being a scheduling problem.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which approach for which job?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Survives gap/ambiguity&lt;/th&gt;
&lt;th&gt;Survives duplicate trigger&lt;/th&gt;
&lt;th&gt;Complexity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local-timezone cron entry&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Lowest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UTC cron + convert inside the job&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frequent UTC tick + claim logical date&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durable execution engine&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Highest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For jobs where a duplicate run is merely wasteful (cache warmers, syncs that overwrite), UTC cron plus in-job conversion is enough. For anything that sends, charges, or emits an external side effect, use the claim. If you want the managed version of the whole model, Temporal gives you schedules with a timezone plus workflow-ID deduplication so a repeated trigger resolves to the same durable execution rather than a second one — at the cost of running a server and rewriting the job as a workflow. If your jobs are already a DAG of data transformations, Airflow's data-interval model bakes the logical clock in as a first-class concept, though it brings a scheduler, metadata database, and upgrade treadmill with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you test this before November?
&lt;/h2&gt;

&lt;p&gt;Don't wait for the transition. Enumerate it — step a fake clock through the window in UTC and assert the run count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_one_run_per_logical_date&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;ny&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ZoneInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;America/New_York&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;logical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;is_due&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ny&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;logical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logical&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# set stands in for the unique constraint
&lt;/span&gt;        &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;       &lt;span class="c1"&gt;# Oct 31, Nov 1, Nov 2 — one each
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point the same test at March 8 and at a European zone. The scheduling logic must be a pure function of &lt;code&gt;(now_utc, tz, wall_time)&lt;/code&gt; for this to be testable at all — which is the real reason to keep it out of the cron expression.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: if you cannot simulate the DST window in a unit test, your scheduling logic lives in the wrong layer.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does cron run a job twice during daylight saving time?&lt;/strong&gt;&lt;br&gt;
Yes, if the cron entry uses a local timezone and the scheduled time falls in the repeated hour — typically 01:00–01:59 on the fall-back date. Times in the skipped hour on the spring-forward date may not run at all. A cron entry in UTC fires exactly once but drifts by an hour in local terms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I store timestamps in UTC or local time?&lt;/strong&gt;&lt;br&gt;
Store instants in UTC (&lt;code&gt;timestamptz&lt;/code&gt; in Postgres) and store the user's IANA timezone name, like &lt;code&gt;Europe/Berlin&lt;/code&gt;, in a separate column. Never store a fixed offset such as &lt;code&gt;+01:00&lt;/code&gt; — it is wrong for half the year and cannot be corrected later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I run a job at 9am in each user's local timezone?&lt;/strong&gt;&lt;br&gt;
Tick every 15 minutes in UTC, resolve 09:00 in each user's IANA zone to a UTC instant for that user's current local date, and process users whose target has passed and who have no row yet for that logical date. Do not precompute a year of trigger timestamps — timezone rules change by government decree, and the tzdata update will invalidate them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If a duplicate run is harmless, put the schedule in UTC and convert to local time inside the job — two lines of &lt;code&gt;zoneinfo&lt;/code&gt; and you are done. If a duplicate run sends an email, charges a card, or posts to a partner API, add the logical-date claim; it costs one table and one insert, and it retires the DST question along with retries, missed triggers, and overlapping runs. Keep the scheduler dumb and the job smart: any platform can fire a UTC tick, but only your code knows what period the work belongs to. Test the March and November windows now, while it is cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/when-is-github-copilot-actually-worth-the-subscription-a-break-even-analysis-5c7p"&gt;When Is GitHub Copilot Actually Worth the Subscription? A Break-Even Analysis&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/admission-control-for-self-hosted-llms-rejecting-requests-before-the-kv-cache-ooms-you-35b8"&gt;Admission Control for Self-Hosted LLMs: Rejecting Requests Before the KV Cache OOMs You&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/should-you-migrate-off-pgvector-run-this-shadow-mode-benchmark-first-3o54"&gt;Should You Migrate Off pgvector? Run This Shadow-Mode Benchmark First&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>python</category>
      <category>backend</category>
      <category>automation</category>
    </item>
    <item>
      <title>Postgres Autovacuum Isn't Keeping Up: Diagnosing Bloat, Long Transactions, and Wraparound Warnings</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Tue, 08 Sep 2026 18:45:17 +0000</pubDate>
      <link>https://dev.to/libme/postgres-autovacuum-isnt-keeping-up-diagnosing-bloat-long-transactions-and-wraparound-warnings-2921</link>
      <guid>https://dev.to/libme/postgres-autovacuum-isnt-keeping-up-diagnosing-bloat-long-transactions-and-wraparound-warnings-2921</guid>
      <description>&lt;p&gt;If your table keeps growing while the row count stays flat, autovacuum is probably running fine and still removing nothing — because something is holding an old snapshot open. Check &lt;code&gt;pg_stat_activity&lt;/code&gt; and &lt;code&gt;pg_replication_slots&lt;/code&gt; before you touch a single autovacuum setting. Only after you've ruled out blockers does tuning thresholds and cost limits make any difference.&lt;/p&gt;

&lt;p&gt;I lost most of a day to this once. A table with a steady ~2 million rows had grown well past what its data should occupy, sequential scans were creeping, and &lt;code&gt;pg_stat_user_tables&lt;/code&gt; showed &lt;code&gt;last_autovacuum&lt;/code&gt; updating every few minutes. Autovacuum was doing its job on schedule and accomplishing nothing, because a reporting connection had been sitting &lt;code&gt;idle in transaction&lt;/code&gt; since the previous deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "autovacuum ran" and "dead rows were removed" are different things
&lt;/h2&gt;

&lt;p&gt;Postgres uses MVCC: an &lt;code&gt;UPDATE&lt;/code&gt; writes a new row version and leaves the old one in place, and a &lt;code&gt;DELETE&lt;/code&gt; just marks the old one dead. Vacuum reclaims those dead versions — but only the ones no live snapshot could still need. The cutoff is the oldest transaction anywhere in the system that might still look backwards.&lt;/p&gt;

&lt;p&gt;That means vacuum's effectiveness is capped by the oldest snapshot on the instance, not by how often it runs. A vacuum that runs every minute against a table protected by a two-hour-old transaction will remove nothing for two hours, and it will report success every time.&lt;/p&gt;

&lt;p&gt;Since Postgres 16, &lt;code&gt;VACUUM VERBOSE&lt;/code&gt; and the autovacuum log line tell you this directly — the output includes how many dead tuples were left behind because they weren't yet removable. That line is the fastest honest signal you have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: autovacuum frequency is irrelevant if the removable cutoff isn't advancing.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I tell if autovacuum is falling behind?
&lt;/h2&gt;

&lt;p&gt;Start with the tables themselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;n_live_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;n_dead_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_dead_tup&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_live_tup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;dead_ratio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;last_autovacuum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;autovacuum_count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;n_dead_tup&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;n_dead_tup&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A dead ratio that climbs across repeated samples is the actual symptom. A high ratio that holds steady on a write-heavy table is often normal.&lt;/p&gt;

&lt;p&gt;Then check whether anything is pinning the cutoff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Oldest transactions and the snapshot they hold&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backend_xmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;xact_start&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;xact_age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;backend_xmin&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;xact_age&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="n"&gt;NULLS&lt;/span&gt; &lt;span class="k"&gt;LAST&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Replication slots pinning old rows (including forgotten ones)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;slot_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;xmin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;catalog_xmin&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_replication_slots&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Prepared (two-phase) transactions nobody committed&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;gid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prepared&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;owner&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_prepared_xacts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An inactive replication slot is the sneakiest of the three: it holds the cutoff indefinitely, produces no query to blame in &lt;code&gt;pg_stat_activity&lt;/code&gt;, and also grows WAL until the disk fills. A slot left behind by a decommissioned read replica or an abandoned CDC pipeline will quietly do both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: three queries — activity, slots, prepared transactions — explain the large majority of "vacuum runs but bloat grows" cases.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually blocks dead tuple removal?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Blocker&lt;/th&gt;
&lt;th&gt;How you spot it&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Long-running query&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;xact_age&lt;/code&gt; large, &lt;code&gt;state = 'active'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Optimize or cap it; set &lt;code&gt;statement_timeout&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;idle in transaction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;state = 'idle in transaction'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt;; fix the client's commit path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inactive replication slot&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pg_replication_slots.active = false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Drop the slot if the consumer is gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;hot_standby_feedback = on&lt;/code&gt; on a replica&lt;/td&gt;
&lt;td&gt;Replica running long reports&lt;/td&gt;
&lt;td&gt;Trade-off: query cancels vs. primary bloat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prepared transaction&lt;/td&gt;
&lt;td&gt;Row in &lt;code&gt;pg_prepared_xacts&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ROLLBACK PREPARED&lt;/code&gt;; audit the XA client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autovacuum genuinely too slow&lt;/td&gt;
&lt;td&gt;Blockers clean, &lt;code&gt;n_dead_tup&lt;/code&gt; still climbing&lt;/td&gt;
&lt;td&gt;Tune thresholds and cost limits (below)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only the last row is a tuning problem. The rest are application or topology problems that tuning will not fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which autovacuum settings are worth changing?
&lt;/h2&gt;

&lt;p&gt;The defaults are deliberately conservative and scale-based, and that's exactly where large tables lose. With &lt;code&gt;autovacuum_vacuum_scale_factor&lt;/code&gt; at 0.2, a 50-million-row table waits for roughly 10 million dead rows before autovacuum even considers it. That threshold is fine for a 10,000-row table and absurd for a large one.&lt;/p&gt;

&lt;p&gt;Set it per table rather than globally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_vacuum_scale_factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;01&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_vacuum_threshold&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_analyze_scale_factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;02&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If autovacuum starts often enough but never finishes before more garbage arrives, the constraint is throughput, not eligibility. Autovacuum sleeps according to &lt;code&gt;autovacuum_vacuum_cost_delay&lt;/code&gt; (2ms by default since Postgres 12) once it has burned through its cost budget. On modern NVMe storage that pacing is usually far more cautious than the hardware needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;autovacuum_vacuum_cost_delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'1ms'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;autovacuum_vacuum_cost_limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;autovacuum_max_workers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- requires restart&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_reload_conf&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Raise the cost limit gradually and watch I/O. Autovacuum competing with peak traffic for the same disk is a real failure mode, just a less common one than autovacuum being throttled into irrelevance.&lt;/p&gt;

&lt;p&gt;Two more worth knowing: &lt;code&gt;maintenance_work_mem&lt;/code&gt; bounds how much dead-tuple state a vacuum can hold, and running out of it forces extra index scan passes over the same table. Postgres 17 reworked that storage so the old 1 GB effective ceiling no longer applies, which is a genuine reason to prioritize that upgrade on bloat-prone databases. And &lt;code&gt;autovacuum_vacuum_insert_scale_factor&lt;/code&gt; (Postgres 13+) covers insert-only tables, which otherwise accumulate unfrozen pages and never get vacuumed on the dead-tuple path at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: per-table thresholds for eligibility, cost delay and limit for throughput — they fix different failures and are not interchangeable.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What do I do about "database must be vacuumed within N transactions"?
&lt;/h2&gt;

&lt;p&gt;This warning means transaction ID age is approaching the wraparound limit. If it keeps climbing, Postgres eventually refuses writes with &lt;code&gt;database is not accepting commands to avoid wraparound data loss&lt;/code&gt;, and recovery requires single-user mode. Treat the warning as a page, not a log line.&lt;/p&gt;

&lt;p&gt;Find the offenders by age:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;age&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relfrozenxid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;xid_age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_total_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;size&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'r'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'m'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pg_catalog'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'information_schema'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;xid_age&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare against &lt;code&gt;autovacuum_freeze_max_age&lt;/code&gt; (200 million by default). Then do the same blocker hunt — an anti-wraparound autovacuum is subject to the same cutoff as any other vacuum, so an open transaction stalls the thing keeping you online. Anti-wraparound vacuums also refuse to yield to lock requests the way ordinary autovacuum does, so a &lt;code&gt;VACUUM (FREEZE)&lt;/code&gt; you kick off manually during a quiet window is often the calmer path than letting one start during peak traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: wraparound warnings are a deadline, and the fix is almost always removing the snapshot holder, not vacuuming harder.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bloat is already there — now what?
&lt;/h2&gt;

&lt;p&gt;Vacuum makes space reusable inside the table; it rarely returns it to the filesystem. To actually shrink files you need a rewrite.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;VACUUM FULL&lt;/code&gt; rewrites the table compactly but takes an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock for the entire operation, so every reader and writer blocks until it finishes. It's the right call for a table you can take offline and the wrong call for anything on the request path.&lt;/p&gt;

&lt;p&gt;If you need the rewrite without the outage, pg_repack does it online by maintaining a shadow copy and swapping at the end, requiring only a brief exclusive lock — at the cost of needing roughly double the table's disk space during the operation and a superuser-installed extension, which some managed providers don't offer. For measuring bloat honestly rather than estimating it from statistics, the pgstattuple extension scans the table and reports real free space, and that full scan is heavy enough that you should run it off-peak.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do I know if autovacuum is running right now?&lt;/strong&gt;&lt;br&gt;
Query &lt;code&gt;pg_stat_progress_vacuum&lt;/code&gt;, which shows each active vacuum's phase, heap blocks scanned, and index vacuum count. If a vacuum has a high &lt;code&gt;index_vacuum_count&lt;/code&gt;, it's making multiple index passes and needs more &lt;code&gt;maintenance_work_mem&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does VACUUM lock the table?&lt;/strong&gt;&lt;br&gt;
Ordinary &lt;code&gt;VACUUM&lt;/code&gt; and autovacuum take a &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt; lock, so reads and writes continue normally; they only conflict with schema changes and other vacuums. &lt;code&gt;VACUUM FULL&lt;/code&gt; is different — it takes &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; and blocks everything for the duration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is n_dead_tup high right after a vacuum finished?&lt;/strong&gt;&lt;br&gt;
Either the dead rows weren't removable yet because an older snapshot still exists, or the counter is simply stale — &lt;code&gt;pg_stat_user_tables&lt;/code&gt; values are estimates updated by the stats collector. Check the vacuum's own log output for how many tuples it reported as not yet removable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Diagnose before you tune: look at &lt;code&gt;pg_stat_activity&lt;/code&gt;, &lt;code&gt;pg_replication_slots&lt;/code&gt;, and &lt;code&gt;pg_prepared_xacts&lt;/code&gt; first, because a held snapshot makes every autovacuum setting irrelevant. If those are clean and dead tuples still accumulate, lower the scale factor per table for eligibility and lower the cost delay for throughput. Treat wraparound warnings as an incident with a deadline. And if you're already carrying bloat you can't afford an outage to remove, pg_repack is the standard answer — assuming your provider lets you install it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/why-your-p99-looks-fine-while-users-complain-averaged-percentiles-and-histogram-buckets-eej"&gt;Why Your p99 Looks Fine While Users Complain: Averaged Percentiles and Histogram Buckets&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/cloudflare-for-developers-what-its-great-at-where-it-bites-and-how-to-actually-use-it-16k6"&gt;Cloudflare for Developers: What It's Great At, Where It Bites, and How to Actually Use It&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/before-you-set-plancachemode-write-the-regression-test-that-proves-it-worked-23l6"&gt;Before You Set plan_cache_mode, Write the Regression Test That Proves It Worked&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>performance</category>
    </item>
    <item>
      <title>UUIDv7 vs ULID vs bigint: Which Primary Key Holds Up When the Table Gets Big?</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Mon, 07 Sep 2026 23:45:49 +0000</pubDate>
      <link>https://dev.to/libme/uuidv7-vs-ulid-vs-bigint-which-primary-key-holds-up-when-the-table-gets-big-4mkg</link>
      <guid>https://dev.to/libme/uuidv7-vs-ulid-vs-bigint-which-primary-key-holds-up-when-the-table-gets-big-4mkg</guid>
      <description>&lt;p&gt;If inserts into a Postgres table got slower as it grew and the primary key is a random UUID (v4), the key is a real suspect: random keys scatter B-tree writes across the whole index instead of concentrating them at one end. UUIDv7 and ULID fix that by putting a millisecond timestamp in the high bits, so new rows land next to each other. &lt;code&gt;bigint&lt;/code&gt; identity is still the smallest and fastest option — it just leaks row counts and makes multi-writer ID generation someone else's problem.&lt;/p&gt;

&lt;p&gt;This is the part of schema design that's cheap to get right on day one and genuinely expensive to change at row 400 million.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do random UUIDs slow down inserts as the table grows?
&lt;/h2&gt;

&lt;p&gt;The symptom is unglamorous: insert latency that used to be flat starts creeping up, correlating with table size rather than traffic. Nothing shows up as a slow query, and &lt;code&gt;EXPLAIN&lt;/code&gt; on the insert looks fine.&lt;/p&gt;

&lt;p&gt;The mechanism is index locality. A B-tree index on a random value means each new row's key sorts into an essentially random leaf page. Once the index is bigger than the memory Postgres can keep it in, most inserts touch a leaf page that isn't in &lt;code&gt;shared_buffers&lt;/code&gt;, so the write turns into a read first. That read is the actual cost.&lt;/p&gt;

&lt;p&gt;There's a second-order effect that surprises people more. With &lt;code&gt;full_page_writes&lt;/code&gt; on (the default), the first modification of a page after a checkpoint writes the &lt;em&gt;entire&lt;/em&gt; page into the WAL, not just the row. Random inserts touch many distinct pages per checkpoint interval; sequential inserts keep hammering the same few. Same rows, meaningfully more WAL — which surfaces as replication lag and backup size, not query latency, so it gets diagnosed as a storage problem.&lt;/p&gt;

&lt;p&gt;Random keys also age the index badly. Sequential inserts fill leaf pages to the fillfactor and move on. Random inserts land in already-full pages, splitting them, and the halves stay half-empty. The index ends up physically larger than its contents justify.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A random primary key doesn't make any single query slow; it makes the whole index stop fitting in cache sooner, which is much harder to spot.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you confirm it's the key and not something else?
&lt;/h2&gt;

&lt;p&gt;Don't take my word for it — the diagnosis is three queries against your own database.&lt;/p&gt;

&lt;p&gt;Start with how much of the index actually fits in memory, and how much dead space it's carrying:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- index size vs table size&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;pg_size_pretty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;index_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;idx_scan&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_indexes&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;indexrelid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%_pkey'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;pg_relation_size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- density of the primary key index (needs the pgstattuple extension)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;pgstattuple&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;index_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leaf_pages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;avg_leaf_density&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;leaf_fragmentation&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pgstatindex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'events_pkey'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;avg_leaf_density&lt;/code&gt; well below ~85% on an append-only table is the fingerprint of random-key page splits; a freshly built index on sequential keys sits near the B-tree fillfactor of 90%.&lt;/p&gt;

&lt;p&gt;Then check whether the buffer cache is losing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heap_blks_read&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;heap_read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heap_blks_hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;heap_hit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idx_blks_hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;
        &lt;span class="k"&gt;nullif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idx_blks_hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idx_blks_read&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;idx_hit_pct&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_statio_user_tables&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If index hit percentage on a write-heavy table is drifting down over weeks while the working set hasn't changed shape, you're watching an index outgrow RAM.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Measure &lt;code&gt;avg_leaf_density&lt;/code&gt; and index cache hit rate on your own table before rewriting a schema — key choice is worth changing when those two numbers say so, not on principle.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  UUIDv7 vs ULID vs bigint: what actually differs?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;bigint&lt;/code&gt; identity&lt;/th&gt;
&lt;th&gt;UUIDv4&lt;/th&gt;
&lt;th&gt;UUIDv7&lt;/th&gt;
&lt;th&gt;ULID&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Storage in Postgres&lt;/td&gt;
&lt;td&gt;8 bytes&lt;/td&gt;
&lt;td&gt;16 bytes (&lt;code&gt;uuid&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;16 bytes (&lt;code&gt;uuid&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;16 bytes as &lt;code&gt;uuid&lt;/code&gt;, ~27 as &lt;code&gt;text&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insert locality&lt;/td&gt;
&lt;td&gt;Sequential&lt;/td&gt;
&lt;td&gt;Random&lt;/td&gt;
&lt;td&gt;Sequential (ms granularity)&lt;/td&gt;
&lt;td&gt;Sequential (ms granularity)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generated by client?&lt;/td&gt;
&lt;td&gt;No (needs the DB)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sortable by creation time&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes, also as a string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Standardized&lt;/td&gt;
&lt;td&gt;SQL standard&lt;/td&gt;
&lt;td&gt;RFC 9562&lt;/td&gt;
&lt;td&gt;RFC 9562&lt;/td&gt;
&lt;td&gt;Community spec, no RFC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native Postgres type&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;uuid&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaks&lt;/td&gt;
&lt;td&gt;Row count, insert order&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;Creation time (ms)&lt;/td&gt;
&lt;td&gt;Creation time (ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows in that table decide most arguments.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;storage row&lt;/strong&gt; is why &lt;code&gt;bigint&lt;/code&gt; still wins on pure efficiency: 8 bytes versus 16 sounds trivial until you count every foreign key, every composite index that includes the key, and every index tuple's share of a page. Doubling key width on a schema with several FK columns per table is a real, permanent tax on how much of your database fits in cache.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;"generated by client" row&lt;/strong&gt; is why people pay that tax anyway. If the ID has to exist before the row does — because the client creates it offline, because you're writing to several shards, because you want to build an object graph in memory and insert it in one round trip — then a database sequence is the wrong tool, and you're choosing among the UUID-shaped options.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Pick &lt;code&gt;bigint&lt;/code&gt; unless something in your architecture genuinely needs an ID before the insert; if it does, the choice narrows to UUIDv7 in almost every case.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Is ULID worth giving up the native &lt;code&gt;uuid&lt;/code&gt; type?
&lt;/h2&gt;

&lt;p&gt;ULID's distinguishing feature is its canonical text form: 26 characters of Crockford base32 that sort lexicographically in the same order as the underlying bytes. If IDs travel through systems that only handle strings — log lines, sorted keys in an object store, a URL path you want to eyeball chronologically — that's a genuine convenience UUID hex doesn't give you.&lt;/p&gt;

&lt;p&gt;The cost in Postgres is that there is no ULID type. Store it as &lt;code&gt;text&lt;/code&gt; and you pay ~27 bytes plus varlena overhead per index entry, and collation-aware comparisons instead of a fixed 16-byte memcmp. Store the same 128 bits in a &lt;code&gt;uuid&lt;/code&gt; column and convert at the application boundary — the approach I'd take — and you've kept only the string formatting.&lt;/p&gt;

&lt;p&gt;ULID also has no RFC behind it. UUIDv7 was standardized in RFC 9562 (2024) and now has first-class support arriving across ecosystems: PostgreSQL 18 ships a built-in &lt;code&gt;uuidv7()&lt;/code&gt; function, and Python's standard library gained &lt;code&gt;uuid.uuid7()&lt;/code&gt; in 3.14. As of mid-2026, choosing ULID means choosing the option with less platform support for a formatting benefit.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ULID is a reasonable choice when the ID's string form is part of your product surface; if it isn't, UUIDv7 gets you the same insert locality with a native type.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you generate UUIDv7 on the Postgres version you actually have?
&lt;/h2&gt;

&lt;p&gt;On PostgreSQL 18 or newer it's built in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;uuidv7&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt;    &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 13–17, generate it in the application (the &lt;code&gt;uuid&lt;/code&gt; npm package and Python's &lt;code&gt;uuid_utils&lt;/code&gt; both do v7), or add this function, which builds a valid v7 by overwriting the first 6 bytes of a v4 with a millisecond timestamp and flipping the version nibble to &lt;code&gt;0111&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;uuid_generate_v7&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;set_bit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;set_bit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;overlay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="n"&gt;uuid_send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
          &lt;span class="k"&gt;PLACING&lt;/span&gt; &lt;span class="k"&gt;substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;int8send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;epoch&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;clock_timestamp&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
          &lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="mi"&gt;52&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="mi"&gt;53&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'hex'&lt;/span&gt;
  &lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;VOLATILE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;gen_random_uuid()&lt;/code&gt; has been built in since PostgreSQL 13, so this needs no extensions. Verify it before trusting it — &lt;code&gt;SELECT uuid_generate_v7();&lt;/code&gt; twice a second apart should give you two values whose first hex characters are ascending.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the honest downside of sequential keys?
&lt;/h2&gt;

&lt;p&gt;Everything monotonic concentrates writes on the rightmost leaf page of the index, and under high insert concurrency that page becomes a lock hotspot. This affects &lt;code&gt;bigint&lt;/code&gt; sequences and UUIDv7 equally. In practice it's a problem at a scale where you're already thinking hard about write throughput, and it's the flip side of the cache benefit — you can't have locality without contention. If you hit it, that's when the random-key layout stops being a bug and starts being the design.&lt;/p&gt;

&lt;p&gt;The other real cost is the timestamp: a UUIDv7 or ULID exposes its creation time to millisecond precision to anyone holding it. Fine for an internal &lt;code&gt;events&lt;/code&gt; table. For a password reset token, or any ID where creation time is sensitive, use a random v4 — that's the case v4 is for, and it's why "always use v7" is bad advice.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Should I migrate an existing UUIDv4 primary key to UUIDv7?&lt;/strong&gt;&lt;br&gt;
Usually not on its own. Changing a primary key type means rewriting the table and every foreign key referencing it, which is a lock-and-backfill project. Do it when the index-density and cache-hit numbers above show real degradation, or fold it into a migration you were already doing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is UUIDv7 slower to generate than UUIDv4?&lt;/strong&gt;&lt;br&gt;
No meaningfully. Both are dominated by the random-bytes call; v7 replaces 48 bits of randomness with a timestamp read. Generation cost is not the reason to choose between them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I sort by primary key instead of by created_at with UUIDv7?&lt;/strong&gt;&lt;br&gt;
Within millisecond resolution, yes — UUIDv7 sorts by creation time. Rows created in the same millisecond have no defined order between them, so if you need a strict total order (cursor pagination, for instance), keep an explicit tiebreaker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If your IDs can come from the database, use &lt;code&gt;bigint&lt;/code&gt; identity — it's half the width and there's no clever alternative that beats it. If they can't, default to UUIDv7 stored in a native &lt;code&gt;uuid&lt;/code&gt; column, using PostgreSQL 18's &lt;code&gt;uuidv7()&lt;/code&gt; or the function above on older versions. Reach for ULID only when the 26-character sortable string is genuinely part of how your system works, and keep UUIDv4 for IDs where leaking a creation timestamp would be a problem. And before you migrate anything, run &lt;code&gt;pgstatindex&lt;/code&gt; on the primary key you already have — the number either justifies the work or it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/how-to-test-search-relevance-before-you-ship-a-ranking-change-29o"&gt;How to Test Search Relevance Before You Ship a Ranking Change&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/why-your-webhook-retries-keep-creating-duplicates-and-the-design-that-actually-fixes-it-33e9"&gt;Why Your Webhook Retries Keep Creating Duplicates (and the Design That Actually Fixes It)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/how-long-should-you-keep-idempotency-keys-a-ttl-strategy-for-webhook-dedup-4ce9"&gt;How Long Should You Keep Idempotency Keys? A TTL Strategy for Webhook Dedup&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
      <category>architecture</category>
    </item>
    <item>
      <title>curl Returns 200 but Your HTTP Client Gets a 404: Debugging Vary and CDN Cache Variants</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sun, 06 Sep 2026 03:18:23 +0000</pubDate>
      <link>https://dev.to/libme/curl-returns-200-but-your-http-client-gets-a-404-debugging-vary-and-cdn-cache-variants-38je</link>
      <guid>https://dev.to/libme/curl-returns-200-but-your-http-client-gets-a-404-debugging-vary-and-cdn-cache-variants-38je</guid>
      <description>&lt;p&gt;If the same URL returns different responses depending on which client asks, you are almost certainly hitting two different cache entries, not two different origin states. A CDN keys its cache on the URL &lt;em&gt;plus&lt;/em&gt; whichever request headers the origin listed in &lt;code&gt;Vary&lt;/code&gt; — most often &lt;code&gt;Accept-Encoding&lt;/code&gt;. &lt;code&gt;curl&lt;/code&gt; sends no &lt;code&gt;Accept-Encoding&lt;/code&gt; by default; nearly every HTTP library sends &lt;code&gt;gzip, deflate&lt;/code&gt;. That one difference puts them on opposite sides of a cache split, and a stale error response can sit in one variant for hours while the other is perfectly healthy.&lt;/p&gt;

&lt;p&gt;I lost most of an afternoon to this. A publishing script called an API endpoint for an article that had just gone live and got a clean &lt;code&gt;404&lt;/code&gt; every single time. Pasting the identical URL into &lt;code&gt;curl&lt;/code&gt; returned &lt;code&gt;200&lt;/code&gt; with the full payload. Same machine, same network, same second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do two clients get different responses from the same URL?
&lt;/h2&gt;

&lt;p&gt;The cache key. RFC 9110 says a response's &lt;code&gt;Vary&lt;/code&gt; header lists the request headers that participate in matching a stored response. So when the origin sends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Vary: Accept-Encoding, Origin, X-Loggedin
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the CDN stops storing "one response for &lt;code&gt;/api/articles/123&lt;/code&gt;" and starts storing "one response per distinct combination of those three headers." Those are &lt;em&gt;variants&lt;/em&gt;. They have independent TTLs, independent ages, and independent contents.&lt;/p&gt;

&lt;p&gt;Now the client difference matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# curl sends no Accept-Encoding by default -&amp;gt; the "identity" variant&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/api/articles/123

&lt;span class="c"&gt;# --compressed sends Accept-Encoding: gzip -&amp;gt; a different variant&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="nt"&gt;--compressed&lt;/span&gt; https://example.com/api/articles/123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="c1"&gt;# requests/urllib3 send "Accept-Encoding: gzip, deflate" unless told otherwise
&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/api/articles/123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my case, the resource had been requested during the brief window right after publication when the origin still answered &lt;code&gt;404&lt;/code&gt;. That &lt;code&gt;404&lt;/code&gt; got stored in the gzip variant with a long edge TTL (&lt;code&gt;x-accel-expires: 172800&lt;/code&gt;), and every subsequent library call matched it. The identity variant was populated later, after the origin was consistent, and served a fresh &lt;code&gt;200&lt;/code&gt;. Nothing retried its way out of that — the two variants had no idea the other existed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The takeaway: when one client succeeds and another fails on the same URL, compare the request headers before you touch the origin.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you confirm it's a cache variant and not a flaky origin?
&lt;/h2&gt;

&lt;p&gt;Diff the response headers of the two clients. &lt;code&gt;Age&lt;/code&gt;, &lt;code&gt;X-Cache&lt;/code&gt;, &lt;code&gt;CF-Cache-Status&lt;/code&gt;, and &lt;code&gt;Vary&lt;/code&gt; will tell you almost immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;enc &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"identity"&lt;/span&gt; &lt;span class="s2"&gt;"gzip"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"--- Accept-Encoding: &lt;/span&gt;&lt;span class="nv"&gt;$enc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept-Encoding: &lt;/span&gt;&lt;span class="nv"&gt;$enc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://example.com/api/articles/123 &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'^(HTTP/|age|vary|x-cache|cf-cache-status|x-accel-expires)'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two signals confirm the diagnosis:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The two runs return &lt;strong&gt;different status codes or different &lt;code&gt;Age&lt;/code&gt; values&lt;/strong&gt;. A large &lt;code&gt;Age&lt;/code&gt; (mine was over 58,000 seconds) on the failing variant means you're reading something cached long before your current problem started.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Vary&lt;/code&gt; names a header that differs between your clients.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If both variants return identical status and near-zero &lt;code&gt;Age&lt;/code&gt;, stop here — the problem is at the origin, and you should be reading origin logs instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The takeaway: a large &lt;code&gt;Age&lt;/code&gt; on the failing request and a small one on the succeeding request is a cache-variant fingerprint, not an origin bug.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What can you actually do when the bad variant belongs to someone else's CDN?
&lt;/h2&gt;

&lt;p&gt;This is the uncomfortable case: it's a third-party API, you can't purge their cache, and support tickets take longer than your deploy. Your only real lever is &lt;strong&gt;changing which variant you land on&lt;/strong&gt;, by controlling the headers you send.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;# Bypass the poisoned gzip variant entirely by matching the identity cache key.
&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept-Encoding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;identity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/api/articles/123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is what fixed my publishing script: one helper that every outbound call to that API goes through, pinning &lt;code&gt;Accept-Encoding: identity&lt;/code&gt;. The cost is real — you give up response compression, so this is a reasonable trade for small JSON payloads and a bad one for large ones. Do it in a single wrapper function, not scattered at each call site, so you can reverse it in one edit later.&lt;/p&gt;

&lt;p&gt;Options, honestly compared:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Works when&lt;/th&gt;
&lt;th&gt;Real cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Change your &lt;code&gt;Accept-Encoding&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Third-party CDN, &lt;code&gt;Vary: Accept-Encoding&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Loses compression; still cached, just a different variant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add a cache-busting query param&lt;/td&gt;
&lt;td&gt;You control nothing but the URL&lt;/td&gt;
&lt;td&gt;Pollutes their cache, may violate rate limits or ToS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Cache-Control: no-cache&lt;/code&gt; request header&lt;/td&gt;
&lt;td&gt;CDN honors it (many don't for anonymous traffic)&lt;/td&gt;
&lt;td&gt;Frequently ignored; unreliable to depend on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Purge by URL&lt;/td&gt;
&lt;td&gt;You own the CDN&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Often purges only one variant&lt;/strong&gt; — verify it clears all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry with backoff&lt;/td&gt;
&lt;td&gt;The origin is genuinely flaky&lt;/td&gt;
&lt;td&gt;Useless here; every retry matches the same poisoned key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the trap. Retries feel like the safe generic fix, and against a cache-key problem they are pure latency with a guaranteed failure at the end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The takeaway: retries cannot fix a cache-key mismatch, because every retry computes the same key.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you stop your own service from creating this problem?
&lt;/h2&gt;

&lt;p&gt;If you operate the origin, the fix is to keep the variant count small and predictable.&lt;/p&gt;

&lt;p&gt;Normalize &lt;code&gt;Accept-Encoding&lt;/code&gt; at the edge rather than passing raw client values through. Client header values are wildly diverse (&lt;code&gt;gzip;q=1.0, deflate;q=0.8, br&lt;/code&gt;, and so on), and if each string becomes its own cache key you get a low hit ratio plus many more chances to store a bad response somewhere. Varnish's default VCL normalizes this for exactly that reason, and Fastly documents the same pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vcl"&gt;&lt;code&gt;&lt;span class="k"&gt;sub&lt;/span&gt; &lt;span class="nf"&gt;vcl_recv&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;req.http.Accept-Encoding&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;req.http.Accept-Encoding&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt; &lt;span class="s2"&gt;"br"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;req.http.Accept-Encoding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"br"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;elsif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;req.http.Accept-Encoding&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt; &lt;span class="s2"&gt;"gzip"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="nv"&gt;req.http.Accept-Encoding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"gzip"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;unset&lt;/span&gt; &lt;span class="nv"&gt;req.http.Accept-Encoding&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three more rules that have saved me repeatedly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never let an error response inherit a success TTL.&lt;/strong&gt; A &lt;code&gt;404&lt;/code&gt; or &lt;code&gt;5xx&lt;/code&gt; cached for two days is the actual damage here; the variant split only decided who saw it. Set short negative-caching TTLs explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never &lt;code&gt;Vary&lt;/code&gt; on high-cardinality headers.&lt;/strong&gt; &lt;code&gt;Vary: User-Agent&lt;/code&gt; multiplies your cache by thousands of variants and effectively disables it. &lt;code&gt;Vary: Cookie&lt;/code&gt; on an authenticated endpoint is worse — one wrong key and you serve someone else's data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Vary: Origin&lt;/code&gt; needs care with CORS.&lt;/strong&gt; If you echo the request &lt;code&gt;Origin&lt;/code&gt; into &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; without varying on it, a cached response hands the wrong origin's CORS header to the next caller. Varying on it is correct, but it is another cache split.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want a managed edge where this behavior is configurable rather than emergent, both Fastly (through VCL) and AWS CloudFront (through cache policies that let you specify exactly which headers enter the key) give you explicit control of the cache key instead of leaving it to whatever &lt;code&gt;Vary&lt;/code&gt; your framework happens to emit. As of mid-2026, check your provider's current documentation before assuming a specific normalization default — this is exactly the kind of behavior that changes between platform versions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The takeaway: your cache key should be something you designed, not a side effect of your framework's default headers.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does curl work but Python requests return 404?&lt;/strong&gt;&lt;br&gt;
Because &lt;code&gt;curl&lt;/code&gt; sends no &lt;code&gt;Accept-Encoding&lt;/code&gt; header by default while &lt;code&gt;requests&lt;/code&gt; sends &lt;code&gt;gzip, deflate&lt;/code&gt;, and if the response carries &lt;code&gt;Vary: Accept-Encoding&lt;/code&gt; those two requests hit different CDN cache entries. Reproduce it with &lt;code&gt;curl --compressed&lt;/code&gt; — if that also fails, you've confirmed the cache variant is the cause rather than anything client-specific.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the Vary header affect the CDN cache key?&lt;/strong&gt;&lt;br&gt;
Yes. Every header listed in &lt;code&gt;Vary&lt;/code&gt; becomes part of the cache key, so the CDN stores a separate copy of the response for each distinct combination of those header values. This is why purging "the URL" sometimes fails to clear the copy your client is actually receiving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I force a request to bypass a bad cached variant?&lt;/strong&gt;&lt;br&gt;
Change a header that appears in the response's &lt;code&gt;Vary&lt;/code&gt; list — most commonly by sending &lt;code&gt;Accept-Encoding: identity&lt;/code&gt; — so your request maps to a different cache key. You cannot rely on &lt;code&gt;Cache-Control: no-cache&lt;/code&gt; in the request, because many CDNs ignore it for anonymous traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If two clients disagree about the same URL, diff their request headers and their &lt;code&gt;Age&lt;/code&gt; values before you suspect the origin. When the poisoned variant lives on a CDN you don't control, your only reliable move is to change the headers that make up the cache key — pin &lt;code&gt;Accept-Encoding&lt;/code&gt; in one shared HTTP helper and accept the loss of compression for small payloads. When you own the origin, normalize &lt;code&gt;Accept-Encoding&lt;/code&gt; at the edge, keep &lt;code&gt;Vary&lt;/code&gt; to low-cardinality headers, and give error responses their own short TTL. Retries are the wrong instinct here; they recompute an identical key and fail identically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/cutting-your-side-projects-cloud-bill-a-checklist-that-doesnt-sacrifice-uptime-17kf"&gt;Cutting Your Side Project's Cloud Bill: A Checklist That Doesn't Sacrifice Uptime&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/the-real-break-even-for-ai-coding-tools-includes-review-time-not-just-typing-saved-48nb"&gt;The Real Break-Even for AI Coding Tools Includes Review Time, Not Just Typing Saved&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/the-boring-stack-manifesto-why-your-startup-probably-doesnt-need-kubernetes-55bo"&gt;The Boring Stack Manifesto: Why Your Startup Probably Doesn't Need Kubernetes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>performance</category>
      <category>api</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your Logging Bill Is a Volume Problem: Model the Cost Before You Switch Vendors</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Fri, 04 Sep 2026 23:01:09 +0000</pubDate>
      <link>https://dev.to/libme/your-logging-bill-is-a-volume-problem-model-the-cost-before-you-switch-vendors-4hmh</link>
      <guid>https://dev.to/libme/your-logging-bill-is-a-volume-problem-model-the-cost-before-you-switch-vendors-4hmh</guid>
      <description>&lt;p&gt;If your log bill jumped and your traffic didn't, the vendor is usually not the problem — the shape of your log volume is. Every managed platform charges on some combination of bytes ingested, what gets indexed for search, and how long it is retained, and most teams push all three knobs to maximum for every line their app emits. Measure bytes per service per level first, cut the noise, and only then compare prices; switching vendors without doing that just moves the same volume to a different invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the log bill grow faster than traffic?
&lt;/h2&gt;

&lt;p&gt;Log volume scales with &lt;em&gt;code paths&lt;/em&gt;, not users. A new service, a debug line someone left in a hot loop, a retry wrapper that logs every attempt, a Kubernetes liveness probe hitting &lt;code&gt;/healthz&lt;/code&gt; every five seconds across a dozen replicas — none of that shows up in your request graph, and all of it shows up on the invoice.&lt;/p&gt;

&lt;p&gt;The other multiplier is structure. Structured JSON logging is the right call, but a log line that carries the full request headers, a serialized user object, and a 4 KB stack trace costs roughly ten times what the equivalent message-plus-fields line costs. You are billed on bytes, and nobody notices bytes until the finance channel does.&lt;/p&gt;

&lt;p&gt;The takeaway: log spend correlates with the number of emit sites in your codebase, which grows monotonically and never gets reviewed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are you actually paying for?
&lt;/h2&gt;

&lt;p&gt;Pricing pages hide behind different units, so compare &lt;em&gt;models&lt;/em&gt;, not sticker numbers. As of September 2026, these are the structural differences that matter — verify current rates on each vendor's pricing page before you build a spreadsheet.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Primary billing unit&lt;/th&gt;
&lt;th&gt;Where the surprise usually hides&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Datadog Logs&lt;/td&gt;
&lt;td&gt;Ingested GB, then separately indexed events by retention period&lt;/td&gt;
&lt;td&gt;Ingest is cheap relative to indexing; teams index everything by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS CloudWatch Logs&lt;/td&gt;
&lt;td&gt;GB ingested + GB stored + GB &lt;em&gt;scanned&lt;/em&gt; by Logs Insights queries&lt;/td&gt;
&lt;td&gt;Query cost is per-scan, so debugging a bad week costs money every time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grafana Cloud Logs (Loki)&lt;/td&gt;
&lt;td&gt;Ingested GB + active series/labels&lt;/td&gt;
&lt;td&gt;High-cardinality labels (user ID, request ID) blow up the index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Elastic Cloud&lt;/td&gt;
&lt;td&gt;Provisioned cluster resources&lt;/td&gt;
&lt;td&gt;You pay for capacity whether or not you fill it; retention is your storage problem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Axiom&lt;/td&gt;
&lt;td&gt;Ingested GB with query included&lt;/td&gt;
&lt;td&gt;Fewer knobs, less control when you want tiered retention&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted Loki&lt;/td&gt;
&lt;td&gt;Object storage + compute you run&lt;/td&gt;
&lt;td&gt;Your on-call rotation now owns the logging system&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The distinction that saves the most money is &lt;strong&gt;ingest vs. index&lt;/strong&gt;. Datadog's model exists precisely because most logs are written once and read never: you can send everything, index only the subset you actually query, and rehydrate the rest when you need it. If your team is on Datadog and hasn't configured exclusion filters, that is the single highest-leverage afternoon of work available to you.&lt;/p&gt;

&lt;p&gt;The takeaway: you are not buying "logs," you are buying ingestion, searchability, and retention as three separately priced things.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I measure my log volume before I shop?
&lt;/h2&gt;

&lt;p&gt;Do not start from the vendor dashboard — start from the bytes your app emits, grouped by the dimensions you can actually act on: service and level.&lt;/p&gt;

&lt;p&gt;If you have a day of JSON logs on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Bytes and line counts grouped by service + level.&lt;/span&gt;
&lt;span class="c"&gt;# Re-serialization makes this an approximation, but the ranking is what matters.&lt;/span&gt;
jq &lt;span class="nt"&gt;-rc&lt;/span&gt; &lt;span class="s1"&gt;'[(.service // "unknown"), (.level // "unknown"), (tostring | length)] | @tsv'&lt;/span&gt; app.jsonl &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="s1"&gt;'\t'&lt;/span&gt; &lt;span class="s1"&gt;'{b[$1"\t"$2] += $3; n[$1"\t"$2]++}
                END {for (k in b) printf "%.1f\t%d\t%s\n", b[k]/1048576, n[k], k}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output columns are MB, line count, service, level. In every audit I have run, the top three rows are the whole conversation — usually one chatty service, one &lt;code&gt;debug&lt;/code&gt; level that was never turned off after an incident, and health checks.&lt;/p&gt;

&lt;p&gt;On AWS, the equivalent first look is stored bytes per log group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws logs describe-log-groups &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'logGroups[].[logGroupName,storedBytes,retentionInDays]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; text &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-k2&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch for &lt;code&gt;None&lt;/code&gt; in the retention column — that is "never expires," which is the CloudWatch default and quietly bills you forever for logs from a service you deleted last year.&lt;/p&gt;

&lt;p&gt;The takeaway: if you cannot name your top three log sources by bytes, you are not ready to compare vendors.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you cut first?
&lt;/h2&gt;

&lt;p&gt;In rough order of savings per hour of effort:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Drop health checks and readiness probes at the logger, not the vendor.&lt;/strong&gt; They are pure volume with zero diagnostic value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn off &lt;code&gt;debug&lt;/code&gt; in production.&lt;/strong&gt; Obvious, and still the most common finding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sample successful requests; keep every error.&lt;/strong&gt; This is where the real reduction lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop logging objects you already have elsewhere.&lt;/strong&gt; Full request headers and serialized ORM entities belong in a trace, not a log line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set retention per source.&lt;/strong&gt; Access logs rarely need the same window as payment events.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Sampling is the one that scares people, because random sampling shreds traces — you get request 3 of a five-line story. Sample &lt;em&gt;deterministically on the trace or request ID&lt;/em&gt; so a sampled request keeps all of its lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sampling.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHash&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// Deterministic: the same key always lands the same way.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sampled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt32BE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mh"&gt;0xffffffff&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wired into an Express access log, with errors and slow requests exempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;pino&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pino&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;randomUUID&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sampled&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./sampling.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pino&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hrtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-request-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;finish&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hrtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;interesting&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;interesting&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/healthz&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;sampled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

    &lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;req.route?.path&lt;/code&gt; rather than &lt;code&gt;req.path&lt;/code&gt;: logging the templated route (&lt;code&gt;/users/:id&lt;/code&gt;) instead of the raw URL keeps cardinality bounded, which matters a lot on label-indexed backends like Loki.&lt;/p&gt;

&lt;p&gt;The takeaway: deterministic sampling on request ID cuts volume without ever handing you half a trace during an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does self-hosting actually cost less?
&lt;/h2&gt;

&lt;p&gt;Self-hosted Loki backed by S3 or R2 is genuinely cheap on storage, because it indexes only labels and treats log bodies as compressed chunks in object storage. If you are running Kubernetes already, have someone who is comfortable owning a stateful service, and your pain is "we generate a lot of low-value logs we still want searchable," Grafana Loki is the option that makes that volume affordable rather than making you delete it.&lt;/p&gt;

&lt;p&gt;The honest cost is not the infrastructure. It is that your logging system becomes a thing that can page you, and it tends to page you during exactly the incidents when you need it. Loki's query performance also degrades badly if your label scheme is wrong, and fixing a label scheme after the fact means reindexing your habits, not just your config.&lt;/p&gt;

&lt;p&gt;For a team under roughly ten engineers with no dedicated platform person, a managed platform is almost always cheaper once you price your own time honestly. Better Stack sits reasonably in that gap if you want managed logs without Datadog's per-feature complexity, though its ecosystem of integrations is thinner than the incumbents'. If you are already deep in AWS and mostly need logs for post-incident forensics rather than daily querying, CloudWatch Logs with aggressive per-group retention is the boring answer that stops the bleeding without a migration.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Reasonable choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Small team, AWS-native, logs read rarely&lt;/td&gt;
&lt;td&gt;CloudWatch with per-group retention set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Want managed, dislike per-feature pricing complexity&lt;/td&gt;
&lt;td&gt;Better Stack or Axiom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Already run Grafana + Kubernetes, high volume&lt;/td&gt;
&lt;td&gt;Self-hosted or Grafana Cloud Loki&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need deep correlation with metrics/APM, have budget&lt;/td&gt;
&lt;td&gt;Datadog with exclusion filters configured on day one&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The takeaway: self-hosting trades a predictable invoice for an unpredictable on-call surface — take that trade only if you already run stateful services well.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do I reduce Datadog log ingestion costs?&lt;/strong&gt;&lt;br&gt;
Configure exclusion filters so high-volume, low-value logs are ingested but not indexed, and sample access logs at the application before they leave the host. Indexing, not ingestion, is usually the larger line item, so cutting what gets indexed changes the bill fastest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is CloudWatch Logs cheaper than Datadog?&lt;/strong&gt;&lt;br&gt;
For storage and ingestion of logs you rarely query, usually yes; for logs you query daily, CloudWatch's per-GB-scanned Logs Insights charges can erase the difference. Compare on your actual query frequency, not just ingested volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does log sampling break debugging?&lt;/strong&gt;&lt;br&gt;
Not if you sample deterministically on the request or trace ID and exempt errors and slow requests, because every retained request keeps its complete set of lines. Random per-line sampling does break debugging, which is why it has a bad reputation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Audit before you migrate: one &lt;code&gt;jq&lt;/code&gt; pass over a day of logs tells you more than any vendor comparison. If you are on Datadog, exclusion filters and deterministic sampling will cut the bill more than switching would. If you are AWS-native and mostly write logs you never read, set CloudWatch retention per log group today. Self-host Loki only if you already operate stateful infrastructure and your volume is genuinely large — otherwise you are trading a line item for an on-call burden.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/an-ai-assisted-code-review-pipeline-that-catches-what-humans-skim-past-5hc0"&gt;An AI-Assisted Code Review Pipeline That Catches What Humans Skim Past&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/why-your-webhook-retries-keep-creating-duplicates-and-the-design-that-actually-fixes-it-33e9"&gt;Why Your Webhook Retries Keep Creating Duplicates (and the Design That Actually Fixes It)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/when-is-github-copilot-actually-worth-the-subscription-a-break-even-analysis-5c7p"&gt;When Is GitHub Copilot Actually Worth the Subscription? A Break-Even Analysis&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>cloud</category>
      <category>saas</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Every Deploy Throws a Few 502s: Where Graceful Shutdown Actually Breaks</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Wed, 02 Sep 2026 21:40:41 +0000</pubDate>
      <link>https://dev.to/libme/every-deploy-throws-a-few-502s-where-graceful-shutdown-actually-breaks-300g</link>
      <guid>https://dev.to/libme/every-deploy-throws-a-few-502s-where-graceful-shutdown-actually-breaks-300g</guid>
      <description>&lt;p&gt;If your error tracker shows a tight cluster of &lt;code&gt;502 Bad Gateway&lt;/code&gt; or &lt;code&gt;ECONNRESET&lt;/code&gt; errors that starts the second a deploy begins and stops about ten seconds later, your app is almost certainly being killed while it still holds in-flight requests. The fix is not one setting. Four separate things have to be true — the process has to receive &lt;code&gt;SIGTERM&lt;/code&gt;, handle it, stop accepting new connections while draining old ones, and stay alive long enough for the load balancer to stop routing to it — and most teams have exactly one or two of those in place.&lt;/p&gt;

&lt;p&gt;I have debugged this on Kubernetes, on ECS, and on a plain Docker host, and the symptom is identical every time: a burst of errors that is too small to page anyone and too regular to be a coincidence. Here is how to find which of the four links is broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do deploys produce 502s at all?
&lt;/h2&gt;

&lt;p&gt;Two clocks are running during a rollout, and nothing synchronizes them.&lt;/p&gt;

&lt;p&gt;Clock one is the orchestrator killing your container. Kubernetes sends &lt;code&gt;SIGTERM&lt;/code&gt;, waits &lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; (30 by default), then sends &lt;code&gt;SIGKILL&lt;/code&gt;. &lt;code&gt;docker stop&lt;/code&gt; sends &lt;code&gt;SIGTERM&lt;/code&gt; and waits 10 seconds before &lt;code&gt;SIGKILL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Clock two is the routing layer forgetting about your pod. In Kubernetes, removing the pod from the Service endpoints propagates asynchronously to every kube-proxy and every ingress controller. On ECS behind an ALB, the task is deregistered from the target group and then the deregistration delay runs down.&lt;/p&gt;

&lt;p&gt;These two happen &lt;strong&gt;concurrently, not in sequence&lt;/strong&gt;. The orchestrator does not wait for the routing layer to catch up before it starts killing your process. So for some window — usually a fraction of a second to a couple of seconds — traffic is still being sent to a container that has already been told to die. If that container exits immediately, every request in that window becomes a 502.&lt;/p&gt;

&lt;p&gt;The takeaway: a 502 during deploy is not a crash, it is a race between the kill signal and the routing update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is your process even receiving SIGTERM?
&lt;/h2&gt;

&lt;p&gt;Before touching shutdown logic, confirm the signal arrives. The most common reason it does not is the container's PID 1.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Shell form: your process runs as a child of /bin/sh, which does NOT forward SIGTERM.&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; npm start&lt;/span&gt;

&lt;span class="c"&gt;# Exec form: your process IS PID 1 and receives the signal.&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shell form runs &lt;code&gt;/bin/sh -c "npm start"&lt;/code&gt;. The shell becomes PID 1, receives &lt;code&gt;SIGTERM&lt;/code&gt;, and does nothing with it — your Node process never hears about it and dies 10 or 30 seconds later by &lt;code&gt;SIGKILL&lt;/code&gt;. Wrapper commands are the other frequent culprit: anything that spawns your server as a child process has to explicitly forward signals, and process-manager wrappers are a common place for them to get swallowed.&lt;/p&gt;

&lt;p&gt;Verify it in about fifteen seconds, on any machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; shutdowntest myimage:latest
docker stop shutdowntest
docker inspect shutdowntest &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.State.ExitCode}} {{.State.OOMKilled}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code &lt;code&gt;0&lt;/code&gt; means you handled the signal and exited cleanly. &lt;code&gt;143&lt;/code&gt; is &lt;code&gt;128 + 15&lt;/code&gt;, meaning &lt;code&gt;SIGTERM&lt;/code&gt; was delivered and the default handler killed you — the signal arrived but nothing handled it. &lt;code&gt;137&lt;/code&gt; is &lt;code&gt;128 + 9&lt;/code&gt;: you were &lt;code&gt;SIGKILL&lt;/code&gt;ed after the grace period expired, which means either you never got the signal or your shutdown handler hung.&lt;/p&gt;

&lt;p&gt;If you need a real init process — because your app legitimately spawns children, or you inherited a shell-form entrypoint you cannot change — Docker's built-in init flag runs &lt;code&gt;tini&lt;/code&gt; as PID 1 and forwards signals to your process correctly. The drawback is that it is a runtime flag, so it only helps where you control the run command; on Kubernetes you add &lt;code&gt;tini&lt;/code&gt; or &lt;code&gt;dumb-init&lt;/code&gt; to the image instead.&lt;/p&gt;

&lt;p&gt;The takeaway: exit code 143 or 137 after a &lt;code&gt;docker stop&lt;/code&gt; means your shutdown handler is either missing or hanging, and no amount of load balancer tuning will fix that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a correct Node shutdown handler looks like
&lt;/h2&gt;

&lt;p&gt;Calling &lt;code&gt;server.close()&lt;/code&gt; is necessary but famously not sufficient. It stops accepting new connections and waits for active ones to finish — but an idle keep-alive connection counts as active, so on a busy service the callback may never fire. Node 18.2 added the two methods that close that gap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;shuttingDown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The readiness endpoint flips first, before anything stops working.&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/readyz&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shuttingDown&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shuttingDown&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;draining&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shuttingDown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;shuttingDown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; received, draining`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Give the routing layer time to notice /readyz is failing.&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;close error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// drain the DB pool&lt;/span&gt;
      &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="c1"&gt;// Idle keep-alive sockets would otherwise hold close() open forever.&lt;/span&gt;
    &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closeIdleConnections&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Hard ceiling, comfortably inside terminationGracePeriodSeconds.&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;drain timed out, forcing exit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closeAllConnections&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;unref&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SIGTERM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SIGINT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details matter more than the rest. The 5-second delay before &lt;code&gt;server.close()&lt;/code&gt; is the whole point — you keep serving normally while the load balancer notices you are unready. The forced-exit timer must be shorter than the orchestrator's grace period, or you get &lt;code&gt;SIGKILL&lt;/code&gt;ed mid-drain and lose the requests you were trying to protect. And attaching a &lt;code&gt;SIGTERM&lt;/code&gt; listener &lt;em&gt;removes&lt;/em&gt; Node's default exit behavior, so if your handler has a bug the process now hangs until it is killed — you have made things worse, not better.&lt;/p&gt;

&lt;p&gt;If you would rather not hand-roll this, the &lt;code&gt;stoppable&lt;/code&gt; and &lt;code&gt;http-terminator&lt;/code&gt; packages wrap the same connection-draining logic behind one call. The tradeoff is a production dependency in the shutdown path, which is the last place you want a surprise.&lt;/p&gt;

&lt;p&gt;The takeaway: sleep first, then close — a shutdown handler that starts closing sockets the instant &lt;code&gt;SIGTERM&lt;/code&gt; lands is just a faster way to drop requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the delay belongs on each platform
&lt;/h2&gt;

&lt;p&gt;The "wait before you stop serving" step can live in the app or in the platform. Pick one place and be explicit about it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;What removes you from routing&lt;/th&gt;
&lt;th&gt;Where to put the drain delay&lt;/th&gt;
&lt;th&gt;Grace period knob&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes&lt;/td&gt;
&lt;td&gt;Endpoint removal, propagated async to kube-proxy / ingress&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;preStop&lt;/code&gt; sleep hook, or in-app delay after &lt;code&gt;SIGTERM&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;terminationGracePeriodSeconds&lt;/code&gt; (default 30)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ECS + ALB&lt;/td&gt;
&lt;td&gt;Target deregistration, then deregistration delay&lt;/td&gt;
&lt;td&gt;Rely on deregistration delay; keep serving until then&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;stopTimeout&lt;/code&gt; (task def), ALB deregistration delay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plain Docker / Compose&lt;/td&gt;
&lt;td&gt;Nothing — you are the routing layer&lt;/td&gt;
&lt;td&gt;In-app delay&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;docker stop -t&lt;/code&gt;, &lt;code&gt;STOPSIGNAL&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed PaaS&lt;/td&gt;
&lt;td&gt;Provider-controlled&lt;/td&gt;
&lt;td&gt;In-app delay only&lt;/td&gt;
&lt;td&gt;Usually fixed, check provider docs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On Kubernetes the &lt;code&gt;preStop&lt;/code&gt; hook is the version most teams should start with, because it works for any language without touching application code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;lifecycle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;preStop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;exec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sleep"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;terminationGracePeriodSeconds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;preStop&lt;/code&gt; runs &lt;em&gt;before&lt;/em&gt; &lt;code&gt;SIGTERM&lt;/code&gt; is sent, and the grace period clock only starts after it finishes — so your app keeps serving normally for those 5 seconds while endpoint removal propagates. Its real drawback is that &lt;code&gt;sleep&lt;/code&gt; must exist in the image, which it does not in &lt;code&gt;distroless&lt;/code&gt; or &lt;code&gt;scratch&lt;/code&gt; builds; there you either use the built-in sleep action available in recent Kubernetes versions or move the delay into the app.&lt;/p&gt;

&lt;p&gt;One rule that is easy to miss: readiness and liveness probes must behave differently during shutdown. Readiness should fail immediately so traffic stops. Liveness must keep passing, or the kubelet restarts a container that was in the middle of a clean exit.&lt;/p&gt;

&lt;p&gt;The takeaway: put the drain delay in exactly one layer, and make sure your grace period is longer than your in-app forced-exit timer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you prove it is fixed?
&lt;/h2&gt;

&lt;p&gt;Do not trust a quiet dashboard — deploy under load and watch. Run a steady, low-rate request stream against the service and trigger a rollout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Any constant-rate client works; the point is a request every 100ms during the rollout.&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://your-service/healthz
  &lt;span class="nb"&gt;sleep &lt;/span&gt;0.1
&lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roll the deployment in another terminal. A correct setup shows &lt;code&gt;200&lt;/code&gt; for every line. If you see a handful of &lt;code&gt;502&lt;/code&gt;s, note how many: at 10 requests per second, six 502s means roughly a 600ms hole, which points at routing propagation rather than a missing handler. Dozens of them across the whole rollout points at the process dying instantly, and you should go back to the &lt;code&gt;docker stop&lt;/code&gt; exit-code check.&lt;/p&gt;

&lt;p&gt;The takeaway: the only convincing test for graceful shutdown is a real rollout under continuous traffic, because the bug only exists inside a window that idle traffic never hits.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does my Kubernetes pod get 502 errors during rolling updates?&lt;/strong&gt;&lt;br&gt;
Because endpoint removal and &lt;code&gt;SIGTERM&lt;/code&gt; happen concurrently, so traffic keeps arriving for a short window after your container starts shutting down. Add a &lt;code&gt;preStop&lt;/code&gt; sleep of a few seconds so the pod keeps serving normally while the routing update propagates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is exit code 143 in Docker?&lt;/strong&gt;&lt;br&gt;
143 is &lt;code&gt;128 + 15&lt;/code&gt;, meaning the process was terminated by &lt;code&gt;SIGTERM&lt;/code&gt; and used the default handler instead of shutting down cleanly. It confirms the signal was delivered — the problem is in your application, not in the container runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does server.close() in Node wait for in-flight requests?&lt;/strong&gt;&lt;br&gt;
Yes, but it also waits for idle keep-alive connections, so on a busy server the callback can be delayed indefinitely. Call &lt;code&gt;server.closeIdleConnections()&lt;/code&gt; right after &lt;code&gt;server.close()&lt;/code&gt; (available from Node 18.2) and keep a forced-exit timer as a backstop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If you only do one thing, check the exit code after &lt;code&gt;docker stop&lt;/code&gt; — that single number tells you whether you have a signal problem or a draining problem, and the two have completely different fixes. On Kubernetes, add a &lt;code&gt;preStop&lt;/code&gt; sleep and a readiness endpoint that fails immediately on &lt;code&gt;SIGTERM&lt;/code&gt;; that combination fixes the majority of deploy-time 502s without any application rewrite. Write the in-app handler when you have real cleanup to do — draining a database pool, finishing a queue job — and always give it a forced-exit timer shorter than the platform grace period. Then prove it with a rollout under constant traffic, because this is a bug that only exists during a window you will never hit by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/why-your-oauth-integration-randomly-returns-invalidgrant-and-how-to-stop-two-workers-from-racing-4ake"&gt;Why Your OAuth Integration Randomly Returns invalid_grant (and How to Stop Two Workers From Racing)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/github-actions-vs-circleci-vs-buildkite-the-real-cost-of-free-ci-minutes-4p57"&gt;GitHub Actions vs CircleCI vs Buildkite: The Real Cost of "Free" CI Minutes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/before-you-set-plancachemode-write-the-regression-test-that-proves-it-worked-23l6"&gt;Before You Set plan_cache_mode, Write the Regression Test That Proves It Worked&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>node</category>
      <category>docker</category>
    </item>
    <item>
      <title>Postgres as a Job Queue vs Redis vs SQS: When Does "Just Use Your Database" Stop Working?</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Wed, 02 Sep 2026 04:14:58 +0000</pubDate>
      <link>https://dev.to/libme/postgres-as-a-job-queue-vs-redis-vs-sqs-when-does-just-use-your-database-stop-working-1f24</link>
      <guid>https://dev.to/libme/postgres-as-a-job-queue-vs-redis-vs-sqs-when-does-just-use-your-database-stop-working-1f24</guid>
      <description>&lt;p&gt;A Postgres table with &lt;code&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt; is a correct, durable job queue, and for most teams it stays correct well past the throughput they actually have. You move off it for two reasons only: sustained enqueue rates high enough that queue churn hurts your primary database, or a need for fan-out to multiple independent consumers. "It feels wrong to use the database" is not one of the reasons.&lt;/p&gt;

&lt;p&gt;I've run all three — a Postgres queue for a scheduling app, Redis Streams for an ingestion pipeline, SQS for anything crossing an account boundary. What follows is the failure mode each one actually produced, because that's the part the "5 ways to build a queue" posts skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a naive Postgres queue lose or double-process jobs?
&lt;/h2&gt;

&lt;p&gt;The broken version everyone writes first looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- DON'T: two workers can read the same row&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'queued'&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'running'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Between the &lt;code&gt;SELECT&lt;/code&gt; and the &lt;code&gt;UPDATE&lt;/code&gt;, another worker runs the same &lt;code&gt;SELECT&lt;/code&gt;. Both get the job. If you "fix" it by wrapping the select in &lt;code&gt;FOR UPDATE&lt;/code&gt; without &lt;code&gt;SKIP LOCKED&lt;/code&gt;, you get the opposite symptom: every worker blocks on the same hot row and your throughput collapses to one job at a time, which shows up in &lt;code&gt;pg_stat_activity&lt;/code&gt; as a pile of workers in &lt;code&gt;Lock&lt;/code&gt; wait events.&lt;/p&gt;

&lt;p&gt;The correct claim is a single statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'running'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;locked_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'queued'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;run_at&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;
  &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;RETURNING&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SKIP LOCKED&lt;/code&gt; (Postgres 9.5 and later) tells the subselect to step over rows another transaction already holds instead of waiting for them. Each worker gets a different job, atomically, with no advisory-lock bookkeeping.&lt;/p&gt;

&lt;p&gt;That handles concurrency. It does not handle a worker that gets OOM-killed halfway through a job — the row sits in &lt;code&gt;running&lt;/code&gt; forever. You need a reaper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'queued'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;locked_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'running'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;locked_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'10 minutes'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That interval is a contract: your jobs must either finish inside it or heartbeat &lt;code&gt;locked_at&lt;/code&gt; while they run. Pick it deliberately rather than copying ten minutes from a blog post.&lt;/p&gt;

&lt;p&gt;The other thing that bites is dead tuples. A queue table is the highest-churn table you own — every job is an insert, one or more updates, and eventually a delete. Autovacuum's default scale factor is proportional to table size, which is wrong for a table that turns over completely every hour. Set it per-table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_vacuum_scale_factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_vacuum_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_analyze_scale_factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;autovacuum_analyze_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, index bloat makes your "instant" dequeue query slowly get slower over weeks, and the symptom — a queue that was fine in month one and mysteriously laggy in month three — looks nothing like its cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Postgres queue's real operational cost is not the dequeue query; it's autovacuum tuning on a high-churn table.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is Redis actually the better queue?
&lt;/h2&gt;

&lt;p&gt;Redis is the right answer when you need low-latency dequeue at high rates and you can tolerate the durability model. The trap is which Redis primitive you pick.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BRPOP&lt;/code&gt; is at-most-once. The moment Redis hands the item to your worker, it's gone from the server. Worker crashes, job is gone, no trace. This is fine for cache warming and catastrophic for payment webhooks, and the API gives you no hint about which situation you're in.&lt;/p&gt;

&lt;p&gt;The reliable patterns are &lt;code&gt;BLMOVE&lt;/code&gt; (Redis 6.2+, replacing the deprecated &lt;code&gt;BRPOPLPUSH&lt;/code&gt;) into a per-worker processing list, or Redis Streams with consumer groups:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;

&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decode_responses&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Create the group once; MKSTREAM makes the stream if it doesn't exist yet.
&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;xgroup_create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jobs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mkstream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BUSYGROUP&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;xreadgroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;worker-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jobs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;entry_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;xack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jobs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;workers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entry_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# only now is it done
&lt;/span&gt;        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;pass&lt;/span&gt;  &lt;span class="c1"&gt;# stays pending; XAUTOCLAIM will hand it to another worker
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unacknowledged entry stays in the group's pending list, and &lt;code&gt;XAUTOCLAIM&lt;/code&gt; reassigns it after an idle threshold. That's the same reaper concept as the Postgres &lt;code&gt;locked_at&lt;/code&gt; sweep, just built in.&lt;/p&gt;

&lt;p&gt;The durability caveat is real and worth stating plainly: with the default &lt;code&gt;appendfsync everysec&lt;/code&gt;, a hard crash can lose about a second of writes. Managed Redis with AOF enabled and replication narrows the window but doesn't close it. If you want Redis Streams with the operational surface managed for you, Upstash is the one that fits a serverless worker fleet, since it bills per request and doesn't hold a connection per worker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Choose Redis for dequeue latency, and only with Streams or &lt;code&gt;BLMOVE&lt;/code&gt; — &lt;code&gt;BRPOP&lt;/code&gt; quietly makes your queue at-most-once.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What does SQS give you that neither gives?
&lt;/h2&gt;

&lt;p&gt;SQS's value is that it is not your infrastructure. No vacuum tuning, no failover, no capacity planning, and a dead-letter queue you configure instead of build. It's the default when a queue crosses a service or account boundary.&lt;/p&gt;

&lt;p&gt;The two things that surprise people:&lt;/p&gt;

&lt;p&gt;Visibility timeout is not a lock, it's a timer. If your job takes longer than the timeout, SQS re-delivers it to another consumer while the first is still working. You must either set the timeout above your worst-case duration or heartbeat with &lt;code&gt;ChangeMessageVisibility&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;

&lt;span class="n"&gt;sqs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sqs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QueueUrl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;QUEUE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WaitTimeSeconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MaxNumberOfMessages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
    &lt;span class="nf"&gt;handle_with_heartbeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;extend&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;change_message_visibility&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;QueueUrl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;QUEUE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ReceiptHandle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ReceiptHandle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;VisibilityTimeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;QueueUrl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;QUEUE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ReceiptHandle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ReceiptHandle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;WaitTimeSeconds=20&lt;/code&gt; — long polling. Leaving it at zero is the single most common way people turn an idle SQS queue into a surprising bill, because empty receives are still billable requests.&lt;/p&gt;

&lt;p&gt;And standard queues are at-least-once with best-effort ordering. Duplicates are a documented property, not a bug. FIFO queues give ordering and deduplication within a message group, at lower throughput per group. Either way, your handler has to be idempotent — which is true of all three options here, so treat it as a fixed cost rather than a differentiator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQS trades local latency and cheap introspection for someone else being on call for the queue itself.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Postgres + SKIP LOCKED&lt;/th&gt;
&lt;th&gt;Redis Streams&lt;/th&gt;
&lt;th&gt;SQS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Durability&lt;/td&gt;
&lt;td&gt;Same as your DB (WAL, PITR)&lt;/td&gt;
&lt;td&gt;Config-dependent; sub-second loss window on crash&lt;/td&gt;
&lt;td&gt;Managed, replicated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery&lt;/td&gt;
&lt;td&gt;At-least-once&lt;/td&gt;
&lt;td&gt;At-least-once with XACK&lt;/td&gt;
&lt;td&gt;At-least-once (standard)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transactional with app writes&lt;/td&gt;
&lt;td&gt;Yes — same commit&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ops burden&lt;/td&gt;
&lt;td&gt;Autovacuum + reaper&lt;/td&gt;
&lt;td&gt;Memory + persistence config&lt;/td&gt;
&lt;td&gt;Effectively none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SELECT * FROM jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;XPENDING&lt;/code&gt;, &lt;code&gt;XINFO&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Console + CloudWatch, no ad-hoc query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost shape&lt;/td&gt;
&lt;td&gt;Free-ish; costs you DB headroom&lt;/td&gt;
&lt;td&gt;Instance or per-request&lt;/td&gt;
&lt;td&gt;Per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Breaks down when&lt;/td&gt;
&lt;td&gt;Enqueue churn competes with app traffic&lt;/td&gt;
&lt;td&gt;You need durable-by-default&lt;/td&gt;
&lt;td&gt;You need transactional enqueue or sub-10ms latency&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The row that decides it most often is "transactional with app writes." If enqueueing a job must be atomic with the row that caused it, Postgres wins outright — everything else needs an outbox table, at which point you've built a Postgres queue anyway and added a second system.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can Postgres handle a job queue in production?&lt;/strong&gt;&lt;br&gt;
Yes. With &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;, a partial index on pending rows, and per-table autovacuum tuning, a single Postgres instance handles job rates well beyond what most applications produce. The limit you hit first is usually contention with your application's own queries on the same instance, not the queue mechanics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Redis a reliable message queue?&lt;/strong&gt;&lt;br&gt;
Only with Streams and consumer groups, or &lt;code&gt;BLMOVE&lt;/code&gt; into a processing list. &lt;code&gt;BRPOP&lt;/code&gt; and &lt;code&gt;LPOP&lt;/code&gt; delete the item at delivery, so a worker crash loses the job. Even with Streams, Redis persistence is configurable and can lose a small window of writes on a hard crash.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between SQS visibility timeout and a lock?&lt;/strong&gt;&lt;br&gt;
A lock is held until released; a visibility timeout expires on a schedule regardless of whether your worker is still running. If processing exceeds the timeout, SQS delivers the same message to another consumer, so long-running jobs must extend the timeout with &lt;code&gt;ChangeMessageVisibility&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Start with Postgres if your jobs are enqueued by the same application that owns the database — the transactional guarantee is worth more than anything the alternatives offer, and one fewer system to operate is a real feature. Move to Redis Streams when dequeue latency or enqueue volume starts showing up in your database's wait events, and accept the persistence trade-off explicitly rather than by default. Reach for SQS when the queue spans services, teams, or AWS accounts, or when nobody on the team wants to own queue infrastructure. All three demand idempotent handlers, so build that first and the migration between them stays cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/supabase-vs-firebase-in-2026-the-migration-questions-nobody-answers-35h8"&gt;Supabase vs Firebase in 2026: The Migration Questions Nobody Answers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/exit-code-137-why-your-container-gets-oomkilled-while-nodes-heap-looks-fine-412p"&gt;Exit Code 137: Why Your Container Gets OOMKilled While Node's Heap Looks Fine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/cursor-vs-github-copilot-vs-windsurf-which-ai-editor-fits-how-you-actually-work-1ih9"&gt;Cursor vs GitHub Copilot vs Windsurf: Which AI Editor Fits How You Actually Work?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Token Streaming Works Locally but Arrives All at Once in Production: Finding the Buffer</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Tue, 01 Sep 2026 07:46:12 +0000</pubDate>
      <link>https://dev.to/libme/token-streaming-works-locally-but-arrives-all-at-once-in-production-finding-the-buffer-1o9g</link>
      <guid>https://dev.to/libme/token-streaming-works-locally-but-arrives-all-at-once-in-production-finding-the-buffer-1o9g</guid>
      <description>&lt;p&gt;If your LLM response streams token-by-token on &lt;code&gt;localhost&lt;/code&gt; but lands as a single blob in production, your application code is almost certainly fine. Something between your process and the browser is holding bytes: a compression layer, a reverse proxy with response buffering on, or a platform that buffers the entire response before returning it. The fix is not to change how you write chunks — it is to walk the request path one hop at a time and find which hop stopped forwarding.&lt;/p&gt;

&lt;p&gt;I have now debugged this on three different stacks, and every time the instinct was wrong. The first suspect is always the streaming code. It is almost never the streaming code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does streaming work on localhost but not in production?
&lt;/h2&gt;

&lt;p&gt;On localhost there is exactly one hop: your process writes to a socket, the browser reads it. In production there are usually four or five — a load balancer, a reverse proxy, possibly a CDN, maybe a serverless runtime wrapper, and compression middleware inside your own app. Each of those is allowed to accumulate bytes before forwarding, and most of them do it by default because buffering makes normal request/response traffic faster.&lt;/p&gt;

&lt;p&gt;Server-Sent Events (SSE) and chunked responses are the exception where buffering destroys the entire point. The bytes still arrive correctly, so nothing errors, no log line fires, and monitoring stays green. You get a silent latency bug: the user waits eight seconds and then sees a wall of text.&lt;/p&gt;

&lt;p&gt;The takeaway: buffering is a correctness-preserving optimization, which is exactly why nothing in your stack will warn you about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I find which hop is buffering?
&lt;/h2&gt;

&lt;p&gt;Bisect the path. Start at the origin process and add one hop per test. This takes about ten minutes and beats guessing.&lt;/p&gt;

&lt;p&gt;Test the app directly, on the box, bypassing every proxy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;--no-buffer&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: text/event-stream"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://127.0.0.1:3000/api/chat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-N&lt;/code&gt; disables curl's own output buffering — leave it off and you will misdiagnose your infrastructure because of your test client. If chunks appear one at a time here, your handler is correct and the problem is downstream.&lt;/p&gt;

&lt;p&gt;To see arrival &lt;em&gt;timing&lt;/em&gt; rather than just final output, stamp each line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sN&lt;/span&gt; https://example.com/api/chat | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; line&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%T.%N&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;%N&lt;/code&gt; is GNU coreutils; on macOS use &lt;code&gt;gdate&lt;/code&gt; from &lt;code&gt;coreutils&lt;/code&gt;, or pipe through &lt;code&gt;ts&lt;/code&gt; from &lt;code&gt;moreutils&lt;/code&gt;.) A healthy stream shows timestamps creeping forward. A buffered one shows every line stamped within the same millisecond at the end — that single observation tells you the response was assembled somewhere and released at once.&lt;/p&gt;

&lt;p&gt;Then repeat against each hop: the internal service address, the proxy address, the public hostname. The first URL that produces same-millisecond timestamps is the hop that owns your bug.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hop&lt;/th&gt;
&lt;th&gt;Typical symptom&lt;/th&gt;
&lt;th&gt;Quick check&lt;/th&gt;
&lt;th&gt;Usual fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Compression middleware (in-app)&lt;/td&gt;
&lt;td&gt;Buffered until ~1KB accumulates, then bursts&lt;/td&gt;
&lt;td&gt;Response has &lt;code&gt;content-encoding: gzip&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Exclude &lt;code&gt;text/event-stream&lt;/code&gt; from the compression filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nginx / reverse proxy&lt;/td&gt;
&lt;td&gt;Fully buffered, released at end&lt;/td&gt;
&lt;td&gt;Works on origin port, not through proxy&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;proxy_buffering off&lt;/code&gt; or send &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDN / edge layer&lt;/td&gt;
&lt;td&gt;Buffered only on the public hostname&lt;/td&gt;
&lt;td&gt;Origin hostname streams, public one does not&lt;/td&gt;
&lt;td&gt;Bypass rule for the route; check edge compression&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serverless wrapper&lt;/td&gt;
&lt;td&gt;Always fully buffered, no config helps&lt;/td&gt;
&lt;td&gt;Same behavior everywhere including local emulation&lt;/td&gt;
&lt;td&gt;Use a runtime with explicit response-streaming support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser client&lt;/td&gt;
&lt;td&gt;Network tab shows chunks, UI updates once&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;curl -N&lt;/code&gt; streams fine&lt;/td&gt;
&lt;td&gt;Read the body as a stream, do not &lt;code&gt;await res.text()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which layers buffer most often?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Compression middleware, inside your own app.&lt;/strong&gt; This is the one people miss because it is not "infrastructure." Express's &lt;code&gt;compression&lt;/code&gt; package compresses anything &lt;code&gt;compressible&lt;/code&gt; considers text — and &lt;code&gt;text/event-stream&lt;/code&gt; is &lt;code&gt;text/*&lt;/code&gt;, so it qualifies. gzip needs a block of input before it emits output, so your tokens sit in the compressor. Exclude the content type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;compression&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compression&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;compression&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/event-stream&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="nx"&gt;compression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same applies to Starlette/FastAPI's &lt;code&gt;GZipMiddleware&lt;/code&gt; — if it is installed, take it out of the path for the streaming route and re-test before touching anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reverse proxies.&lt;/strong&gt; nginx buffers proxied responses by default (&lt;code&gt;proxy_buffering on&lt;/code&gt;), which is the right default for HTML and the wrong one for SSE. Scope the exception to the route rather than turning it off globally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/chat&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://app_upstream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_buffering&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_cache&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;gzip&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;3600s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you cannot edit the proxy config — a shared ingress, a managed load balancer — the application can ask for the same behavior with a response header. nginx honors &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; per response, which makes it the one reverse proxy where a hosted app can fix its own streaming without touching infrastructure config.&lt;/p&gt;

&lt;p&gt;Set the headers correctly at the origin regardless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi.responses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;StreamingResponse&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;gen&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;llm_stream&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data: [DONE]&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;StreamingResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;gen&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;media_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text/event-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cache-Control&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no-cache, no-transform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Accel-Buffering&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;no-transform&lt;/code&gt; is the part people leave out. It tells intermediaries they may not re-encode the body, which is a standards-level way of asking CDNs not to compress your stream.&lt;/p&gt;

&lt;p&gt;The takeaway: fix compression first, proxy buffering second — in that order, because a compression layer will keep buffering even after you turn proxy buffering off.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if the platform itself refuses to stream?
&lt;/h2&gt;

&lt;p&gt;Some runtimes buffer the whole response by design, and no header will change that. Classic API Gateway integrations return the response as a single payload — the Lambda finishes, then the gateway replies. If you are streaming from AWS, Lambda Function URLs with the response-streaming invoke mode and &lt;code&gt;awslambda.streamifyResponse&lt;/code&gt; are the supported path, at the cost of moving off API Gateway and losing the request-level features you had there.&lt;/p&gt;

&lt;p&gt;Platform-side, as of September 2026, Vercel, Cloudflare Workers, and Fly.io all support streamed responses on their standard runtimes, so a stream that dies there is usually your own middleware rather than the platform. Cloudflare Workers is the one I reach for when the workload is pure passthrough streaming, though the CPU-time model makes it a poor fit if you do heavy work in the same request. If you need a long-lived connection with ordinary Node or Python semantics and no execution-time ceiling, a container platform like Fly.io removes the constraint entirely — you trade the zero-ops story for managing a process that stays up.&lt;/p&gt;

&lt;p&gt;The takeaway: before optimizing your streaming code, confirm the runtime is even allowed to send bytes before the handler returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is the bug possibly in the browser?
&lt;/h2&gt;

&lt;p&gt;Sometimes. If the network panel shows chunks arriving over time but the UI updates once, the server is fine and the client is collecting the body. &lt;code&gt;await res.text()&lt;/code&gt; and &lt;code&gt;await res.json()&lt;/code&gt; both wait for completion by definition. Read the stream:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/chat&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getReader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;{ stream: true }&lt;/code&gt; — without it, a multi-byte UTF-8 character split across two chunks decodes to a replacement character. That one bites when non-English output enters the picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does my SSE stream only work locally and not behind nginx?&lt;/strong&gt;&lt;br&gt;
nginx has &lt;code&gt;proxy_buffering on&lt;/code&gt; by default, so it accumulates the proxied response before forwarding it. Set &lt;code&gt;proxy_buffering off&lt;/code&gt; for that location, or have your application send the &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; response header, which nginx honors per response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does gzip break server-sent events?&lt;/strong&gt;&lt;br&gt;
It can. gzip needs input to accumulate before it emits compressed output, so a compression layer holds your tokens even when every proxy in front is configured to stream. Exclude &lt;code&gt;text/event-stream&lt;/code&gt; from compression and send &lt;code&gt;Cache-Control: no-cache, no-transform&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I test if a response is actually streaming?&lt;/strong&gt;&lt;br&gt;
Run &lt;code&gt;curl -N --no-buffer&lt;/code&gt; against the endpoint and timestamp each line as it arrives. If all lines carry effectively the same timestamp, something buffered the response; if the timestamps spread out, the stream is live. Test the origin port first, then each hop outward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If tokens stream on localhost and not in production, spend your time bisecting hops rather than rewriting the handler. Check in-app compression first, then reverse-proxy buffering, then the CDN, then the runtime — in that order, since each earlier layer can mask a fix applied to a later one. Set &lt;code&gt;Cache-Control: no-cache, no-transform&lt;/code&gt; and &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; at the origin as a permanent default; they cost nothing and preempt the two most common causes. And keep the timestamped &lt;code&gt;curl -N&lt;/code&gt; one-liner in your notes — it converts a vague "streaming feels broken" report into a specific hop in about ten minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/notion-vs-obsidian-for-engineering-docs-what-breaks-at-team-scale-4aa0"&gt;Notion vs Obsidian for Engineering Docs: What Breaks at Team Scale&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/your-password-reset-emails-are-going-to-spam-choosing-between-resend-postmark-and-amazon-ses-3jak"&gt;Your Password Reset Emails Are Going to Spam: Choosing Between Resend, Postmark, and Amazon SES&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/vercel-pros-and-cons-when-its-the-right-host-and-when-youll-regret-it-422h"&gt;Vercel Pros and Cons: When It's the Right Host, and When You'll Regret It&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llm</category>
      <category>devops</category>
      <category>api</category>
      <category>performance</category>
    </item>
    <item>
      <title>Exit Code 137: Why Your Container Gets OOMKilled While Node's Heap Looks Fine</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sun, 30 Aug 2026 12:08:58 +0000</pubDate>
      <link>https://dev.to/libme/exit-code-137-why-your-container-gets-oomkilled-while-nodes-heap-looks-fine-412p</link>
      <guid>https://dev.to/libme/exit-code-137-why-your-container-gets-oomkilled-while-nodes-heap-looks-fine-412p</guid>
      <description>&lt;p&gt;Exit code 137 is &lt;code&gt;128 + 9&lt;/code&gt; — your process was killed with SIGKILL, and in a container that almost always means the cgroup memory limit was hit and the kernel OOM killer picked your process. The reason your heap graph looks innocent is that the limit applies to the container's &lt;em&gt;working set&lt;/em&gt; (RSS: V8 heap + native buffers + thread stacks + mapped code + allocator overhead), while &lt;code&gt;heapUsed&lt;/code&gt; only measures one slice of that. Debugging this starts by measuring the right number, not by raising the limit.&lt;/p&gt;

&lt;p&gt;I have burned entire afternoons on this, twice, because the dashboard I was staring at was accurate and irrelevant at the same time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I confirm it was actually an OOM kill and not a crash?
&lt;/h2&gt;

&lt;p&gt;A Node process that runs out of &lt;em&gt;heap&lt;/em&gt; dies loudly. You get a stack trace and a nonzero-but-not-137 exit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An OOM &lt;em&gt;kill&lt;/em&gt; is silent. There is no stack trace, no &lt;code&gt;uncaughtException&lt;/code&gt; handler firing, no last log line — SIGKILL cannot be trapped. That silence is the diagnostic signal. Confirm it from the outside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Kubernetes&lt;/span&gt;
kubectl describe pod my-api-7d9f | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A3&lt;/span&gt; &lt;span class="s1"&gt;'Last State'&lt;/span&gt;
&lt;span class="c"&gt;#     Last State:     Terminated&lt;/span&gt;
&lt;span class="c"&gt;#       Reason:       OOMKilled&lt;/span&gt;
&lt;span class="c"&gt;#       Exit Code:    137&lt;/span&gt;

&lt;span class="c"&gt;# Plain Docker&lt;/span&gt;
docker inspect my-api &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.State.OOMKilled}} {{.State.ExitCode}}'&lt;/span&gt;
&lt;span class="c"&gt;# true 137&lt;/span&gt;

&lt;span class="c"&gt;# The kernel's side of the story, if you can reach the host&lt;/span&gt;
dmesg &lt;span class="nt"&gt;-T&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'memory cgroup out of memory'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;Reason: OOMKilled&lt;/code&gt; is absent and you still see 137, someone or something sent SIGKILL — a &lt;code&gt;docker kill&lt;/code&gt;, a failed liveness probe escalating past its grace period, or a node draining. Those are different bugs with different fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a clean stack trace means you ran out of heap; total silence plus 137 means you ran out of container.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does &lt;code&gt;heapUsed&lt;/code&gt; stay flat while RSS climbs?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;process.memoryUsage()&lt;/code&gt; returns five numbers, and most dashboards graph the wrong one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;memoryUsage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;rss&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;mb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rss&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                   &lt;span class="c1"&gt;// what the kernel counts against your limit&lt;/span&gt;
    &lt;span class="na"&gt;heapTotal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;mb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;heapTotal&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;       &lt;span class="c1"&gt;// V8 heap reserved&lt;/span&gt;
    &lt;span class="na"&gt;heapUsed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;mb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;heapUsed&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;         &lt;span class="c1"&gt;// what most dashboards graph&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;mb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;external&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;         &lt;span class="c1"&gt;// C++ objects bound to JS objects&lt;/span&gt;
    &lt;span class="na"&gt;arrayBuffers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;mb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arrayBuffers&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// Buffers, TypedArrays — off-heap&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every byte you read from a socket, a file stream, an image pipeline, or a database driver's binary protocol lands in &lt;code&gt;arrayBuffers&lt;/code&gt;/&lt;code&gt;external&lt;/code&gt;, not in old space. A service that buffers uploads or concatenates response bodies can hold hundreds of megabytes off-heap while &lt;code&gt;heapUsed&lt;/code&gt; sits at a tidy 90 MB. V8's garbage collector is also under no pressure to run, because from its point of view there is plenty of room — the memory it cares about is not the memory that is running out.&lt;/p&gt;

&lt;p&gt;Native modules make this worse in a way that is easy to misread. &lt;code&gt;sharp&lt;/code&gt;, &lt;code&gt;canvas&lt;/code&gt;, &lt;code&gt;grpc&lt;/code&gt;, and similar addons allocate through their own libraries; leaked handles there never appear in a heap snapshot at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: if RSS and &lt;code&gt;heapUsed&lt;/code&gt; diverge, stop reading heap snapshots — the memory you are losing is not on the JavaScript heap.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What limit is Node actually enforcing inside a container?
&lt;/h2&gt;

&lt;p&gt;Two limits exist, they are set in different places, and nothing makes them agree:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;Set by&lt;/th&gt;
&lt;th&gt;Covers&lt;/th&gt;
&lt;th&gt;What happens when you hit it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;V8 old-space max&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--max-old-space-size&lt;/code&gt; / &lt;code&gt;NODE_OPTIONS&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;JS heap only&lt;/td&gt;
&lt;td&gt;Aggressive GC, then a fatal &lt;code&gt;heap out of memory&lt;/code&gt; error with a stack trace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cgroup memory max&lt;/td&gt;
&lt;td&gt;Docker &lt;code&gt;--memory&lt;/code&gt;, k8s &lt;code&gt;resources.limits.memory&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Everything: RSS, page cache, allocator overhead&lt;/td&gt;
&lt;td&gt;SIGKILL, exit 137, no output&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the real limit from inside the container rather than trusting the manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# cgroup v2 (the default on current distros as of mid-2026)&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/memory.max /sys/fs/cgroup/memory.current

&lt;span class="c"&gt;# cgroup v1&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/memory/memory.limit_in_bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node tries to size its default heap from the memory it detects, but that detection depends on the runtime version and on which cgroup version the host uses, so I no longer rely on it in either direction. Set it explicitly, below the container limit, leaving room for everything off-heap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# container limit 1024Mi → heap ceiling 768Mi, ~256Mi for buffers, stacks, allocator&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_OPTIONS="--max-old-space-size=768"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point of that gap is not politeness. A heap ceiling under the container ceiling converts a silent SIGKILL into a loud, debuggable &lt;code&gt;JavaScript heap out of memory&lt;/code&gt; crash with a stack trace — you trade an unexplained restart for an actual error message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: set &lt;code&gt;--max-old-space-size&lt;/code&gt; to roughly 70–80% of the container limit so leaks surface as crashes you can read instead of kills you can't.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does memory keep growing when there is no leak?
&lt;/h2&gt;

&lt;p&gt;Three causes I hit far more often than an actual leak:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Allocator fragmentation.&lt;/strong&gt; glibc's malloc creates per-thread arenas, and a process with many threads (Node's libuv pool, native addons) can hold on to a lot of freed-but-unreturned memory. Capping arenas with &lt;code&gt;MALLOC_ARENA_MAX=2&lt;/code&gt; or switching the image to jemalloc is worth &lt;em&gt;testing&lt;/em&gt; — it flattened RSS growth in one image-processing service of mine and did nothing measurable in two others, so treat it as an experiment, not a fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Page cache counted as usage.&lt;/strong&gt; &lt;code&gt;memory.current&lt;/code&gt; includes file cache, which inflates the number without being a real problem. Kubernetes evaluates the working set, not raw usage, so graph &lt;code&gt;container_memory_working_set_bytes&lt;/code&gt; — Prometheus with cAdvisor metrics exposes exactly this series, and it is the one that lines up with an OOM kill. Its drawback is granularity: at a 15–30 second scrape interval it will miss a spike that kills you in under a scrape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unbounded concurrency.&lt;/strong&gt; Memory tracks in-flight requests. A queue consumer that pulls 500 messages at once, or an endpoint with no upload size cap, is not leaking — it is just allowed to use more memory than exists. If you want a managed alternative to burning a week on this, Datadog's container memory views correlate working-set spikes with the specific request traces that caused them, at the cost of another per-host bill.&lt;/p&gt;

&lt;p&gt;For genuine leaks, capture a snapshot from a live process without a debugger attached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;--heapsnapshot-signal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SIGUSR2 server.js
&lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nt"&gt;-USR2&lt;/span&gt; &amp;lt;pid&amp;gt;   &lt;span class="c"&gt;# writes a .heapsnapshot into cwd; diff two of them in Chrome DevTools&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take one snapshot after warmup and one an hour later, and compare — a single snapshot tells you almost nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: raising the memory limit is a legitimate fix only after you have ruled out fragmentation, page cache, and unbounded concurrency.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does exit code 137 mean in Docker?&lt;/strong&gt;&lt;br&gt;
It means the process received SIGKILL (128 + signal 9). In containers, this is nearly always the kernel OOM killer enforcing the cgroup memory limit. Check &lt;code&gt;docker inspect --format '{{.State.OOMKilled}}'&lt;/code&gt; — if it prints &lt;code&gt;true&lt;/code&gt;, it was memory, not your code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is my Node.js container OOMKilled when heap usage is low?&lt;/strong&gt;&lt;br&gt;
Because the container limit counts RSS, which includes off-heap memory: Buffers and TypedArrays (&lt;code&gt;arrayBuffers&lt;/code&gt;), native addon allocations, thread stacks, and allocator overhead. Log &lt;code&gt;process.memoryUsage().rss&lt;/code&gt; alongside &lt;code&gt;heapUsed&lt;/code&gt;; if they diverge, the growth is off-heap and heap snapshots will not show it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I set --max-old-space-size in a container?&lt;/strong&gt;&lt;br&gt;
Yes. Set it explicitly at roughly 70–80% of the container's memory limit rather than relying on Node's automatic detection, which varies by version and cgroup setup. The gap leaves headroom for off-heap memory and turns silent OOM kills into readable heap-exhaustion errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If you see 137 with no logs, confirm &lt;code&gt;OOMKilled&lt;/code&gt; first — that single check separates a memory problem from a lifecycle problem and saves you from debugging the wrong thing. Then graph RSS and working set, not &lt;code&gt;heapUsed&lt;/code&gt;, and set &lt;code&gt;--max-old-space-size&lt;/code&gt; under the container limit so future failures arrive with a stack trace attached. Reach for heap snapshots only once RSS and &lt;code&gt;heapUsed&lt;/code&gt; are growing together; if they have diverged, look at buffers, native addons, and concurrency limits instead. Raising the limit is a valid last step, but it is a decision you should make with the working-set graph in front of you, not as a reflex.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/build-vs-buy-authentication-in-2026-auth0-clerk-supabase-auth-or-roll-your-own-3mc8"&gt;Build vs Buy: Authentication in 2026 (Auth0, Clerk, Supabase Auth, or Roll Your Own)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/why-your-docker-build-takes-11-minutes-in-ci-when-it-takes-20-seconds-locally-3foe"&gt;Why Your Docker Build Takes 11 Minutes in CI When It Takes 20 Seconds Locally&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/zapier-vs-make-vs-n8n-when-paying-per-task-stops-making-sense-327b"&gt;Zapier vs Make vs n8n: When Paying Per Task Stops Making Sense&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>docker</category>
      <category>kubernetes</category>
      <category>performance</category>
    </item>
    <item>
      <title>Feature Flags: When a Managed Service Beats a Config Table You Own</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Fri, 28 Aug 2026 21:47:54 +0000</pubDate>
      <link>https://dev.to/libme/feature-flags-when-a-managed-service-beats-a-config-table-you-own-4f20</link>
      <guid>https://dev.to/libme/feature-flags-when-a-managed-service-beats-a-config-table-you-own-4f20</guid>
      <description>&lt;p&gt;If all you need is "turn this code path on for everyone or nobody," a &lt;code&gt;feature_flags&lt;/code&gt; table plus a cached lookup will serve you for years and cost nothing. You start paying for a managed service when non-engineers need to flip flags, when you need percentage rollouts targeted at stable user buckets, and when someone has to answer "who turned that on, and when?" The trap in both directions is the same: teams underestimate the failure mode where the flag layer is unreachable, and they underestimate how much dead flag code accumulates.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a flag service actually sell you?
&lt;/h2&gt;

&lt;p&gt;Not the boolean. Evaluating a boolean is five lines of code. What you're buying, roughly in order of how much they matter to a small team:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A UI safe enough for someone who can't deploy.&lt;/strong&gt; The whole point of a kill switch is that it works at 2am when the person on call isn't the person who wrote the feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An audit trail.&lt;/strong&gt; Who changed which flag, when, from what value. This is the thing you'll miss the first time a flag change causes an incident and nobody can reconstruct the timeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistent bucketing across services.&lt;/strong&gt; A user in the 10% rollout in your API must land in the same bucket in your web frontend and your background worker. Get that wrong and you get the "the feature flickers on and off for one customer" bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time propagation and targeting beyond percentages.&lt;/strong&gt; Streaming updates so a flip takes effect in seconds, plus per-plan, per-org, per-region rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first two are an afternoon of work. The last two are where homegrown implementations quietly rot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: you're not paying for flag evaluation, you're paying for safe non-engineer access and an audit trail you'll only value in hindsight.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when the flag service is unreachable?
&lt;/h2&gt;

&lt;p&gt;This is the failure mode that turns a cost decision into an availability decision, and it's the one I've actually been burned by. The symptom looks like this in your logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[warn] LaunchDarkly client initialization timed out after 5000ms
[warn] feature flag "new_checkout" evaluated to fallback value: false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two independent things go wrong. First, SDK initialization is usually awaited during boot, so an unreachable provider adds its full timeout to startup — which, in a rolling deploy with a readiness probe, looks like a failed deploy rather than a flag problem. Second, when init fails every flag returns the fallback passed at the call site, and in most codebases half of those were typed as &lt;code&gt;false&lt;/code&gt; without thinking. If &lt;code&gt;false&lt;/code&gt; means "use the old code path," fine. If &lt;code&gt;false&lt;/code&gt; means "disable the rate limiter," you've just had an outage caused by your flag vendor.&lt;/p&gt;

&lt;p&gt;The fix has nothing to do with which vendor you choose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// flags.js — never let the flag provider decide whether your app starts.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEFAULTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./flag-defaults.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// committed to the repo&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;DEFAULTS&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;race&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
      &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flag init timeout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;unref&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;initFlags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// whatever your SDK calls its "ready" promise&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allFlags&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flags: serving committed defaults&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DEFAULTS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;initFlags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;flag&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three properties matter: a short, explicit init timeout; a defaults file in version control, so the fallback is a reviewed decision; and a boot sequence that continues regardless. Most SDKs cache their last known ruleset in memory and ride out a network blip — but only if the process already initialized, which is exactly what doesn't hold during a deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: pick your fallback values deliberately and commit them to the repo, because a flag provider outage will read them all at once.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is a managed flag service worth paying for?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Config table you own&lt;/th&gt;
&lt;th&gt;Managed service&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Only engineers flip flags&lt;/td&gt;
&lt;td&gt;Fine&lt;/td&gt;
&lt;td&gt;Overkill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Support or product needs to flip flags&lt;/td&gt;
&lt;td&gt;Painful (you build the UI)&lt;/td&gt;
&lt;td&gt;This is the product&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Boolean on/off only&lt;/td&gt;
&lt;td&gt;Fine&lt;/td&gt;
&lt;td&gt;Overkill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Percentage rollouts across multiple services&lt;/td&gt;
&lt;td&gt;Doable, easy to get subtly wrong&lt;/td&gt;
&lt;td&gt;Solved, consistent bucketing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need "who changed what, when"&lt;/td&gt;
&lt;td&gt;You build audit logging&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flags read on hot paths (per-request)&lt;/td&gt;
&lt;td&gt;Cache locally, trivial&lt;/td&gt;
&lt;td&gt;Needs local eval or a proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client-side / mobile flags&lt;/td&gt;
&lt;td&gt;You build a public endpoint&lt;/td&gt;
&lt;td&gt;Solved, with edge caching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compliance or SSO requirements&lt;/td&gt;
&lt;td&gt;Your problem&lt;/td&gt;
&lt;td&gt;Usually an upper-tier feature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fewer than ~10 live flags at a time&lt;/td&gt;
&lt;td&gt;Fine&lt;/td&gt;
&lt;td&gt;Hard to justify&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rows deserve emphasis. Client-side flags are where homegrown solutions get expensive: you now need a public, cacheable, low-latency endpoint that doesn't leak targeting rules to the browser. And pricing — as of mid-2026 the common models are per-seat, per-monthly-active-context, or per-request for edge evaluation. Check which shape your usage is: a two-person team with millions of anonymous visitors gets a very different bill under a per-context model than under a per-seat one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the moment a non-engineer needs to flip a flag, or a flag has to reach the browser, the build-your-own math stops working.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the homegrown version actually look like?
&lt;/h2&gt;

&lt;p&gt;Smaller than people expect, if you keep the scope honest. A table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;feature_flags&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;key&lt;/span&gt;             &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;enabled&lt;/span&gt;         &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;rollout_percent&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
                  &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rollout_percent&lt;/span&gt; &lt;span class="k"&gt;between&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt;      &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;updated_by&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And deterministic bucketing, which is the part worth getting right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;inRollout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flagKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;percent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;percent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;percent&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Hash the flag key with the user id so a user isn't in the same&lt;/span&gt;
  &lt;span class="c1"&gt;// bucket for every flag, and so the bucket is stable across services.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;flagKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readUInt32BE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;percent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hashing &lt;code&gt;flagKey:userId&lt;/code&gt; rather than &lt;code&gt;userId&lt;/code&gt; alone matters: if you hash the user id only, the same unlucky 10% of users are the guinea pigs for every single rollout you ever do. Cache the table in memory with a short TTL, refresh in the background, and never block a request on the database read.&lt;/p&gt;

&lt;p&gt;What the homegrown version does &lt;em&gt;not&lt;/em&gt; give you is flag hygiene. Dead flags are the real cost: every stale flag is a branch no test covers and no one dares delete. Run something like this in CI and diff it against your flag store:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oh&lt;/span&gt; &lt;span class="s2"&gt;"flag('[a-z0-9_.-]*'"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; src | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway: a homegrown flag system is a weekend to build and a permanent tax to clean up, and the cleanup is the part teams skip.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do the main options differ as of mid-2026?
&lt;/h2&gt;

&lt;p&gt;If you want the mature, safe default with the deepest targeting and experimentation tooling, LaunchDarkly is the option that handles streaming updates, per-environment audit trails, and a self-hosted relay for high-volume evaluation — with the caveat that it is the priciest of the group and the relay is extra infrastructure you operate. If you want an open-source core you can self-host on your own Postgres and pay for only when you need SSO and richer access control, Unleash is the one designed to run in your infrastructure, though front-end flags require running its edge/proxy component so targeting rules never reach the browser. If you want open source with a hosted option and a simpler mental model that includes remote config alongside flags, Flagsmith covers that ground, with a smaller ecosystem and fewer third-party integrations than LaunchDarkly. If your team already runs PostHog for product analytics, its built-in flags are worth using before adding a second vendor, keeping in mind flags are a secondary product there rather than the main event.&lt;/p&gt;

&lt;p&gt;Whichever you pick, wrap it behind OpenFeature, the vendor-neutral flag SDK standard under the CNCF, so a later migration is a provider swap rather than a codebase-wide refactor. The abstraction is thin, but provider maturity varies by language — check your runtime before committing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: choose on hosting model and access control first, because targeting features have largely converged across these tools.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Should I use a database table for feature flags?&lt;/strong&gt;&lt;br&gt;
Yes, if only engineers flip flags, you have a handful of them, and they're boolean. Cache the table in memory with a short TTL so you're not adding a query to every request, and add an &lt;code&gt;updated_by&lt;/code&gt; column from day one — reconstructing who flipped what is the first thing you'll want during an incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if LaunchDarkly or my flag provider goes down?&lt;/strong&gt;&lt;br&gt;
Initialized SDK clients keep serving their last cached ruleset, so running processes usually survive a provider outage. The dangerous window is process startup: if the client can't initialize, every flag falls back to the default value at its call site, so commit an explicit defaults file and set a short init timeout instead of blocking boot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many feature flags is too many?&lt;/strong&gt;&lt;br&gt;
There's no universal number, but if you can't name what every live flag is currently gating, you have flag debt. Give each flag an owner and a removal date at creation time, and treat a permanently-on flag as a code change you owe the repo, not a setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If you're a small team where engineers own the flips and flags are on/off, build the table — it's an afternoon, and a managed service would mostly bill you for a UI nobody opens. Move to a managed service when support or product needs to flip flags without a deploy, when you need consistent percentage bucketing across more than one service, or when flags have to reach the browser. Self-host Unleash or Flagsmith if you'd rather spend ops time than budget; take LaunchDarkly if targeting depth and audit trails matter more than the invoice. Either way, set explicit fallback defaults and a removal date per flag on the day you create it — that discipline matters more than which vendor's logo is on the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/why-your-query-is-fast-in-psql-but-slow-from-the-app-postgres-plan-caching-explained-3omj"&gt;Why Your Query Is Fast in psql but Slow From the App: Postgres Plan Caching Explained&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/notion-vs-obsidian-for-engineering-docs-what-breaks-at-team-scale-4aa0"&gt;Notion vs Obsidian for Engineering Docs: What Breaks at Team Scale&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/why-your-postgres-migration-locked-the-whole-table-and-the-pattern-that-doesnt-38k4"&gt;Why Your Postgres Migration Locked the Whole Table (and the Pattern That Doesn't)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>saas</category>
      <category>tooling</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
