<?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>Is Your CDN Actually Caching? Audit Hit Ratio and Asset Fan-Out Before You Migrate Buckets</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Mon, 24 Aug 2026 22:04:53 +0000</pubDate>
      <link>https://dev.to/libme/is-your-cdn-actually-caching-audit-hit-ratio-and-asset-fan-out-before-you-migrate-buckets-4ef5</link>
      <guid>https://dev.to/libme/is-your-cdn-actually-caching-audit-hit-ratio-and-asset-fan-out-before-you-migrate-buckets-4ef5</guid>
      <description>&lt;p&gt;Before you move a bucket to escape egress fees, measure two things: your &lt;strong&gt;byte-weighted cache hit ratio&lt;/strong&gt; and your &lt;strong&gt;requests per unique object&lt;/strong&gt;. A low hit ratio caused by unstable cache keys is a bug you can fix in an afternoon; a low hit ratio caused by genuinely one-off assets is a structural fact no CDN can fix, and that's the case where zero-egress storage earns the migration. Most teams never separate the two and end up paying a migration to hide a caching bug.&lt;/p&gt;

&lt;p&gt;A commenter on an earlier post about storage egress put it well: audit how many &lt;em&gt;unique&lt;/em&gt; assets you serve versus how many come from cache, before you touch anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why doesn't my egress bill match my download volume?
&lt;/h2&gt;

&lt;p&gt;Because two meters are running, and they bill different bytes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CDN data-out&lt;/strong&gt;: every byte delivered to a browser, cached or not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Origin egress&lt;/strong&gt;: only the bytes your CDN had to fetch from the bucket — your cache &lt;em&gt;misses&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you serve 5 TB to users at a 95% hit ratio, your bucket only emitted about 250 GB. That's the number a storage migration changes. The other 4.75 TB is billed by whoever runs your edge, and moving the bucket doesn't touch it.&lt;/p&gt;

&lt;p&gt;This is why the AWS-native path confuses people: S3-to-CloudFront transfer is free, so putting CloudFront in front of S3 relabels the egress bill as CloudFront data-out rather than removing it. Cloudflare doesn't meter CDN bandwidth per GB, though as of mid-2026 its terms restrict serving large volumes of non-HTML content on non-enterprise plans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; a storage migration only moves the origin-egress meter, so measure cache misses — not total downloads — before estimating the savings.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I measure cache hit ratio without buying an analytics add-on?
&lt;/h2&gt;

&lt;p&gt;Start with two curls. The first fills the cache, the second should hit it:&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="nv"&gt;BASE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'https://%s'&lt;/span&gt; &lt;span class="s2"&gt;"cdn.example.invalid"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;# your edge hostname&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-sSI&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;&lt;span class="s2"&gt;/u/9f3ab2/avatar.webp"&lt;/span&gt; &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;'^(cf-cache-status|x-cache|age|cache-control|vary):'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the second response still says &lt;code&gt;Miss from cloudfront&lt;/code&gt; or &lt;code&gt;cf-cache-status: MISS&lt;/code&gt;, your cache key is unstable and no TTL tuning will help. An &lt;code&gt;Age&lt;/code&gt; header that resets to 0 on every request is the same symptom with a friendlier face.&lt;/p&gt;

&lt;p&gt;Two curls tell you about one object. For the real number, parse the logs. CloudFront access logs are tab-separated with a &lt;code&gt;#Fields:&lt;/code&gt; header line, so map columns instead of hardcoding positions:&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="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Byte-weighted hit ratio and asset fan-out from CloudFront access logs.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Counter&lt;/span&gt;

&lt;span class="n"&gt;HIT&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;Hit&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;RefreshHit&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;OriginShieldHit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;requests_by_obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes_by_obj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;hit_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;miss_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&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="n"&gt;opener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;open&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.gz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nb"&gt;open&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;opener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rt&lt;/span&gt;&lt;span class="sh"&gt;"&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;fh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fh&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;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#Fields:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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="k"&gt;continue&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;fields&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;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;zip&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;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
            &lt;span class="n"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&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;sc-bytes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&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;requests_by_obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cs-uri-stem&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="mi"&gt;1&lt;/span&gt;
            &lt;span class="n"&gt;bytes_by_obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cs-uri-stem&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="n"&gt;size&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;row&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-edge-result-type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;HIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;hit_bytes&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;miss_bytes&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;size&lt;/span&gt;

&lt;span class="n"&gt;total_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hit_bytes&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;miss_bytes&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;total_bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sys&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no log rows parsed - check the file paths&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;uniq&lt;/span&gt; &lt;span class="o"&gt;=&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;requests_by_obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bytes_by_obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;most_common&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;byte-weighted hit ratio : &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hit_bytes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total_bytes&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;origin egress (misses)  : &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;miss_bytes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1e9&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; GB&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unique objects          : &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;uniq&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="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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requests per object     : &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requests_by_obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;uniq&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;top 50 objects          : &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;total_bytes&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; of bytes&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;For Cloudflare Logpush (newline-delimited JSON), the same numbers stream out of jq plus awk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zcat logs/&lt;span class="k"&gt;*&lt;/span&gt;.log.gz &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'[.CacheCacheStatus, .EdgeResponseBytes, .ClientRequestURI] | @tsv'&lt;/span&gt; &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;'
      { bytes += $2; if ($1 == "hit") hits += $2
        if (!($3 in seen)) { seen[$3] = 1; uniq++ } }
      END { printf "hit ratio : %.1f%%\nunique URIs: %d\nreq/URI   : %.1f\n",
                   100 * hits / bytes, uniq, NR / uniq }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two caveats. &lt;code&gt;sc-bytes&lt;/code&gt; is bytes sent to the viewer, so ranged and aborted downloads make it an approximation of origin fetch bytes — good enough to size a decision, not to reconcile an invoice. Counting only &lt;code&gt;hit&lt;/code&gt; as a hit is deliberately conservative: &lt;code&gt;revalidated&lt;/code&gt; still costs an origin round trip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; byte-weighted hit ratio is the only version of the metric that maps to money — a 99% request hit ratio means nothing if the 1% misses are your video files.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes a hit ratio quietly collapse?
&lt;/h2&gt;

&lt;p&gt;Rarely short TTLs, which is what everyone checks first. It's almost always that the cache key isn't stable across requests for the same bytes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Presigned URLs.&lt;/strong&gt; An S3 presigned URL carries &lt;code&gt;X-Amz-Signature&lt;/code&gt; and &lt;code&gt;X-Amz-Date&lt;/code&gt; in the query string, and both change on every generation. If query parameters are in your cache key, every request is a unique object to the edge and your hit ratio is structurally zero. This is the big one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache-busting parameters&lt;/strong&gt; added by an image component or analytics wrapper (&lt;code&gt;?v=&amp;lt;timestamp&amp;gt;&lt;/code&gt;, &lt;code&gt;?_=&amp;lt;random&amp;gt;&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forwarded cookies.&lt;/strong&gt; Include a session cookie in the key and your hit ratio equals your per-user re-download rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Vary: User-Agent&lt;/code&gt; or &lt;code&gt;Vary: Accept&lt;/code&gt;&lt;/strong&gt; from an image-transform layer, fragmenting one object into many variants — as do multiple hostnames for the same asset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check your cache policy's query-string, header, and cookie inclusion lists, then re-run the two-curl test with a stripped URL to confirm which one it is.&lt;/p&gt;

&lt;p&gt;Presigned URLs can't be made cacheable — you can only stop using them on the hot path. What works is a path-based token with a quantized window, so every request inside that window produces byte-identical URLs:&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;WINDOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;  &lt;span class="c1"&gt;# seconds; also your worst-case revocation lag
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;asset_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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="nb"&gt;bytes&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;slot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&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="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;WINDOW&lt;/span&gt;
    &lt;span class="n"&gt;mac&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="si"&gt;}&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="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="k"&gt;return&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;/a/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slot&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;mac&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="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your edge (a worker, Lambda@Edge, or your own origin) recomputes the HMAC for the current and previous slot and rejects anything else. The drawback is real: revocation is coarse, so a link stays valid up to one full window after you cut access. Fine for avatars and exports; for sensitive objects, keep presigned URLs and accept that those bytes are always origin egress.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; if assets are served through rotating signatures, your hit ratio isn't low — it's zero by construction, and that's a URL design problem, not a storage problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do the two numbers actually tell me to do?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requests per unique object&lt;/th&gt;
&lt;th&gt;Byte-weighted hit ratio&lt;/th&gt;
&lt;th&gt;Diagnosis&lt;/th&gt;
&lt;th&gt;Fix first&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;High (20+)&lt;/td&gt;
&lt;td&gt;High (90%+)&lt;/td&gt;
&lt;td&gt;Working as intended&lt;/td&gt;
&lt;td&gt;Nothing — origin egress is already small&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High (20+)&lt;/td&gt;
&lt;td&gt;Low (under 60%)&lt;/td&gt;
&lt;td&gt;Unstable cache key or too-short TTLs&lt;/td&gt;
&lt;td&gt;Cacheability; a migration would hide a bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Low (1–3)&lt;/td&gt;
&lt;td&gt;Low, and it can't go higher&lt;/td&gt;
&lt;td&gt;Genuine long tail: one-off generated files&lt;/td&gt;
&lt;td&gt;Storage with no egress charge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Low overall, bytes concentrated in a few objects&lt;/td&gt;
&lt;td&gt;Mixed&lt;/td&gt;
&lt;td&gt;A handful of large files dominate&lt;/td&gt;
&lt;td&gt;Long TTLs on those; leave the rest alone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The third row is the one people miss. AI-generated assets — a rendered PDF, a one-off image, an export requested once and never again — have a fan-out near 1. Caching them is nearly pointless: you pay the origin fetch either way, and the object is evicted before a second request that never comes. If you want the managed version of this, Cloudflare R2 is the one that removes the egress meter entirely instead of discounting it, so long-tail traffic stops being a variable cost. Backblaze B2 gets most of the way there with free egress up to a multiple of what you store — and free to Cloudflare's network via the Bandwidth Alliance — until your read-to-store ratio drifts past that multiple.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; fan-out near 1 is the only signal that reliably justifies migrating for egress, because it's the one thing better caching cannot improve.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I turn this into a before/after number?
&lt;/h2&gt;

&lt;p&gt;Take the miss bytes the script printed — that's the monthly origin egress you actually pay for. Price that figure two ways: per GB above whatever allowance applies (S3, and B2 past its included multiple), or zero (R2, and B2 within the allowance). Then add operations, because with fan-out near 1 every delivered object is roughly one origin GET, and on R2 the Class A writes on the upload side are the pricier meter. Storage is usually the rounding error, which is why ranking providers by per-GB storage rate ranks them wrong.&lt;/p&gt;

&lt;p&gt;Run the script against a month of logs before and after any cacheability fix. In my experience the fix lands first and the number moves enough that the migration case either becomes obvious or evaporates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; the migration decision is one subtraction — miss bytes times the egress rate, minus the operations you'd pay at the new provider.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;How do I check if my CDN is caching my S3 files?&lt;/strong&gt;&lt;br&gt;
Request the same URL twice with &lt;code&gt;curl -sSI&lt;/code&gt; and read the &lt;code&gt;x-cache&lt;/code&gt; (CloudFront) or &lt;code&gt;cf-cache-status&lt;/code&gt; (Cloudflare) header. The first response will be a miss; if the second is also a miss, something in your cache key — query strings, cookies, or a &lt;code&gt;Vary&lt;/code&gt; header — differs between the two requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does putting a CDN in front of S3 eliminate egress fees?&lt;/strong&gt;&lt;br&gt;
No, it moves them. S3-to-CloudFront transfer is free, so the bucket's egress drops to near zero, but CloudFront then bills data-out to viewers. The saving comes from cache hits reducing origin fetches, not from the CDN being free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's a good cache hit ratio for user-uploaded assets?&lt;/strong&gt;&lt;br&gt;
For shared assets like avatars and thumbnails, aim for 90%+ byte-weighted. For one-off generated files with a fan-out near 1, even 30% may be the ceiling — there the ratio isn't the problem to solve, and egress pricing models are.&lt;/p&gt;

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

&lt;p&gt;Run the log script before you run a migration. If requests per unique object is high and the hit ratio isn't, you have a cache-key bug — fix the presigned URLs or the stray query strings and the egress bill drops without changing providers. If fan-out is genuinely near 1, caching has nothing left to give, and zero-egress storage like R2 (or B2 within its free-egress multiple) is the honest answer. Either way, the two numbers cost you an afternoon and tell you which of those two projects you're actually signed up for.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/s3-vs-r2-vs-b2-for-user-uploads-when-egress-fees-should-change-your-architecture-ip5"&gt;S3 vs R2 vs B2 for User Uploads: When Egress Fees Should Change Your Architecture&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/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;/ul&gt;

</description>
      <category>cloud</category>
      <category>aws</category>
      <category>devops</category>
      <category>performance</category>
    </item>
    <item>
      <title>Why Your OAuth Integration Randomly Returns invalid_grant (and How to Stop Two Workers From Racing)</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Mon, 24 Aug 2026 08:14:41 +0000</pubDate>
      <link>https://dev.to/libme/why-your-oauth-integration-randomly-returns-invalidgrant-and-how-to-stop-two-workers-from-racing-4ake</link>
      <guid>https://dev.to/libme/why-your-oauth-integration-randomly-returns-invalidgrant-and-how-to-stop-two-workers-from-racing-4ake</guid>
      <description>&lt;p&gt;If your third-party OAuth integration works for days and then dies with &lt;code&gt;{"error":"invalid_grant"}&lt;/code&gt;, the cause is usually not clock skew, a wrong client secret, or an expired consent. It is two of your own processes calling the token endpoint with the same refresh token at the same time. When the provider rotates refresh tokens, the second call presents a token that was already consumed, and many providers treat that as replay and revoke the whole token family — which is why the user has to click "Reconnect" instead of just retrying.&lt;/p&gt;

&lt;p&gt;The fix is to make refresh a single-flight operation per integration, and to stop treating "the access token expired" as something every worker discovers independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does &lt;code&gt;invalid_grant&lt;/code&gt; actually mean?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;invalid_grant&lt;/code&gt; is the most overloaded error in OAuth 2.0. The spec assigns it to any grant that is "invalid, expired, revoked, does not match the redirect URI, or was issued to another client," so providers pile several unrelated conditions into one string. In practice, on a refresh call it means one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The refresh token expired (idle-expiry, common on providers that expire unused tokens).&lt;/li&gt;
&lt;li&gt;The user or an admin revoked the app's access.&lt;/li&gt;
&lt;li&gt;The client credentials do not match the token.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The refresh token was already used once and the provider rotates.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only the last one comes and goes. If your error rate is bursty, correlates with traffic spikes or with cron minute boundaries, and affects healthy accounts that just worked, you are looking at rotation plus concurrency. Refresh-token rotation is a recommended practice in the OAuth 2.0 Security Best Current Practice and it is the default in OAuth 2.1 drafts, so assume any provider you integrate with today may rotate — check the response body rather than the docs, because the behavior sometimes differs per app registration.&lt;/p&gt;

&lt;p&gt;The tell is simple: if the token response includes a &lt;code&gt;refresh_token&lt;/code&gt; field with a value different from the one you sent, that provider rotates, and every refresh is a state mutation you have to serialize.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does this only blow up in production?
&lt;/h2&gt;

&lt;p&gt;Locally you run one process. In production you run a web dyno, three queue workers, and a scheduled job — all sharing one row in your integrations table.&lt;/p&gt;

&lt;p&gt;The race is boring and unavoidable without a lock:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Worker A reads the token row, sees &lt;code&gt;expires_at&lt;/code&gt; is in the past, POSTs to the token endpoint.&lt;/li&gt;
&lt;li&gt;Worker B reads the same row 40 ms later, sees the same stale &lt;code&gt;expires_at&lt;/code&gt;, POSTs the same refresh token.&lt;/li&gt;
&lt;li&gt;The provider rotates: A gets a new pair, B's request presents an already-redeemed token.&lt;/li&gt;
&lt;li&gt;B gets &lt;code&gt;invalid_grant&lt;/code&gt;. Depending on the provider, the family is now revoked and A's brand-new token is dead too.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last step is what makes this worth fixing properly instead of retrying. A retry loop makes it worse: it turns one replayed token into five, which looks exactly like the attack that reuse detection exists to catch.&lt;/p&gt;

&lt;p&gt;The window is not "when the token expires" — it is "when several workers first notice."&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you serialize refresh across processes?
&lt;/h2&gt;

&lt;p&gt;If you already run Postgres, advisory locks are the cheapest correct answer: no extra infrastructure, no lease expiry to reason about, and the lock is released automatically when the transaction ends, including on a crashed connection.&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;oauth_tokens&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;integration_id&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;access_token&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;refresh_token&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;prev_refresh_token&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;expires_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="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;rotated_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;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;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;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;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="n"&gt;REFRESH_SKEW&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;seconds&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="n"&gt;TOKEN_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://provider.example.com/oauth/token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_lock_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;integration_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Stable 64-bit signed key for pg_advisory_xact_lock.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&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;oauth:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;integration_id&lt;/span&gt;&lt;span class="si"&gt;}&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="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="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;big&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signed&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;def&lt;/span&gt; &lt;span class="nf"&gt;_read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;integration_id&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;conn&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;SELECT access_token, refresh_token, expires_at &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FROM oauth_tokens WHERE integration_id = %s&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="n"&gt;integration_id&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&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;get_access_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;integration_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;now&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;now&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="n"&gt;access&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;integration_id&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;expires_at&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;REFRESH_SKEW&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&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;return&lt;/span&gt; &lt;span class="n"&gt;access&lt;/span&gt;  &lt;span class="c1"&gt;# fast path: no lock, no network call
&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;conn&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;SET LOCAL lock_timeout = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;15s&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;conn&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;SELECT pg_advisory_xact_lock(%s)&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;_lock_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;integration_id&lt;/span&gt;&lt;span class="p"&gt;),))&lt;/span&gt;

        &lt;span class="c1"&gt;# Re-read inside the lock: someone may have refreshed while we waited.
&lt;/span&gt;        &lt;span class="n"&gt;access&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;integration_id&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;expires_at&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;REFRESH_SKEW&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&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;return&lt;/span&gt; &lt;span class="n"&gt;access&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;httpx&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="n"&gt;TOKEN_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;data&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;grant_type&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;refresh_token&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;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&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="mf"&gt;10.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&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;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;body&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="n"&gt;conn&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;
            UPDATE oauth_tokens
               SET prev_refresh_token = refresh_token,
                   access_token       = %s,
                   refresh_token      = %s,
                   expires_at         = %s,
                   rotated_at         = now()
             WHERE integration_id = %s
            &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="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="n"&gt;body&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;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="n"&gt;now&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;seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expires_in&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
                &lt;span class="n"&gt;integration_id&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;return&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&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;Three details carry most of the value. The fast path avoids taking a lock on every API call, so the hot path stays a single indexed read. The re-read inside the lock is the part people skip — without it, every worker that queued behind the lock still performs its own refresh the moment it acquires one. And &lt;code&gt;body.get("refresh_token", refresh)&lt;/code&gt; keeps working on providers that do not rotate, so the same code path handles both.&lt;/p&gt;

&lt;p&gt;The honest drawback: this holds a database transaction open across an HTTP call. With a 10-second client timeout and a 15-second &lt;code&gt;lock_timeout&lt;/code&gt;, worst case you pin one pooled connection for ~25 seconds. If your pool is small or you refresh thousands of integrations concurrently, move the refresh into a dedicated worker instead of doing it inline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you refresh on 401 or before expiry?
&lt;/h2&gt;

&lt;p&gt;Refresh proactively. Reacting to a 401 means every worker discovers expiry at the same moment — you have built a thundering herd on purpose, and you now need the lock to be perfect because it is on the critical path of every request.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;REFRESH_SKEW&lt;/code&gt; above (two minutes) means the first worker to come within the skew window refreshes while the current token is still valid, and everyone else keeps using the still-good token instead of blocking. You still keep 401 handling as a fallback for revocation and for providers that expire tokens earlier than advertised, but it should be rare enough that a 401 in your logs is a signal rather than routine noise.&lt;/p&gt;

&lt;p&gt;Treat a 401 on a token you refreshed 30 seconds ago as an alert, not a retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which locking strategy fits your stack?
&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;Safe across processes&lt;/th&gt;
&lt;th&gt;Extra infra&lt;/th&gt;
&lt;th&gt;Main weakness&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;In-process mutex / &lt;code&gt;asyncio.Lock&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Only protects one process; useless with multiple dynos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis &lt;code&gt;SET key val NX EX 30&lt;/code&gt; lease&lt;/td&gt;
&lt;td&gt;Mostly&lt;/td&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;td&gt;Lease can expire mid-refresh; no fencing, so two refreshes are still possible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres &lt;code&gt;pg_advisory_xact_lock&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;None if you have Postgres&lt;/td&gt;
&lt;td&gt;Holds a connection across the HTTP call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; on the token row&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Same connection cost, plus it blocks unrelated readers of that row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dedicated refresh worker (one consumer per integration)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Queue&lt;/td&gt;
&lt;td&gt;More moving parts; needs its own liveness monitoring&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you would rather not own token storage at all, Nango handles OAuth token refresh and rotation for third-party integrations as a managed service, and Merge does the same as part of a unified API layer — both cost real money per connected account and both put a vendor between you and the provider's raw API, which matters the day you need an endpoint they have not mapped.&lt;/p&gt;

&lt;p&gt;For most teams running a single Postgres, the advisory lock is the correct amount of machinery.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you tell a race apart from a real revocation?
&lt;/h2&gt;

&lt;p&gt;Log the discriminating fields, not the error string. On every refresh attempt, record the integration id, the worker/process id, a hash prefix of the refresh token you sent (never the token), and the &lt;code&gt;rotated_at&lt;/code&gt; you read. Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two refreshes for the same integration within seconds, from different workers → race.&lt;/li&gt;
&lt;li&gt;One refresh, &lt;code&gt;invalid_grant&lt;/code&gt;, and the token was last rotated hours ago → genuine expiry or revocation; re-consent is the only fix.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;invalid_grant&lt;/code&gt; on a token whose hash prefix matches &lt;code&gt;prev_refresh_token&lt;/code&gt; → you replayed a rotated token, usually from a retry or a stale cached copy in process memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last case is why the &lt;code&gt;prev_refresh_token&lt;/code&gt; column exists. It costs one text field and turns an unexplainable error into a one-query diagnosis.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;What causes invalid_grant on refresh token requests?&lt;/strong&gt;&lt;br&gt;
Most often the refresh token was already used and the provider rotates refresh tokens, so the second use is rejected as replay. The other common causes are user revocation, idle expiry of an unused refresh token, and client credentials that do not match the token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does every OAuth provider rotate refresh tokens?&lt;/strong&gt;&lt;br&gt;
No, but you cannot assume either way. Check whether the token response contains a &lt;code&gt;refresh_token&lt;/code&gt; different from the one you sent, and write your storage code so it persists a new one whenever it appears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I just retry after invalid_grant?&lt;/strong&gt;&lt;br&gt;
No. If rotation caused it, retrying replays an already-redeemed token and can trigger the provider's reuse detection, which revokes the entire token family and forces the user to reconnect. Re-read your stored token first; if it changed, another worker already refreshed and you should use the new one.&lt;/p&gt;

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

&lt;p&gt;If you integrate with any provider that returns a new &lt;code&gt;refresh_token&lt;/code&gt; on refresh, treat token refresh as a mutation that must happen exactly once per integration and put a cross-process lock around it — a Postgres advisory lock is enough for most teams and needs no new infrastructure. Refresh proactively inside a skew window rather than reacting to 401s, so workers never discover expiry simultaneously. Keep the previous refresh token in a column purely for diagnosis; it is the difference between "random OAuth errors" and a five-minute root cause. And never retry &lt;code&gt;invalid_grant&lt;/code&gt; blindly — that is the one response where a retry can turn a recoverable error into a forced reconnect.&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/stop-guessing-your-webhook-dedup-ttl-derive-it-from-the-delivery-contract-1978"&gt;Stop Guessing Your Webhook Dedup TTL: Derive It From the Delivery Contract&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;/ul&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>security</category>
      <category>postgres</category>
    </item>
    <item>
      <title>S3 vs R2 vs B2 for User Uploads: When Egress Fees Should Change Your Architecture</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sun, 23 Aug 2026 21:17:11 +0000</pubDate>
      <link>https://dev.to/libme/s3-vs-r2-vs-b2-for-user-uploads-when-egress-fees-should-change-your-architecture-ip5</link>
      <guid>https://dev.to/libme/s3-vs-r2-vs-b2-for-user-uploads-when-egress-fees-should-change-your-architecture-ip5</guid>
      <description>&lt;p&gt;If your app stores files that users repeatedly download — avatars, exports, video, generated PDFs — the storage line on your bill is almost never the problem. Egress is. Amazon S3 charges per gigabyte leaving the network, Cloudflare R2 charges nothing for egress and makes its money on storage plus operations, and Backblaze B2 gives you free egress up to a multiple of what you store and bills beyond that. Pick based on your read-to-store ratio, not on the per-GB storage rate everyone quotes.&lt;/p&gt;

&lt;p&gt;That ratio is the whole decision, and most teams never measure it before choosing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually drives an object storage bill?
&lt;/h2&gt;

&lt;p&gt;Four meters run at once, and only the first is the one people compare:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Storage&lt;/strong&gt; — GB-months sitting at rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Egress&lt;/strong&gt; — bytes leaving the provider's network toward the public internet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operations&lt;/strong&gt; — writes/lists (usually "Class A") and reads (usually "Class B"), billed per million.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything in front of it&lt;/strong&gt; — CDN requests, image transforms, function invocations.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a backup target, storage dominates and the cheapest per-GB wins. For user uploads served back to browsers, egress dominates and can exceed storage by an order of magnitude — a 50 GB bucket whose files get pulled a hundred times a month is a 5 TB egress bill attached to a rounding-error storage bill.&lt;/p&gt;

&lt;p&gt;Here's the shape of each provider as of mid-2026. I'm deliberately describing &lt;em&gt;pricing models&lt;/em&gt; rather than quoting rates, because rates move and a stale number in a blog post is worse than no number:&lt;/p&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;Amazon S3&lt;/th&gt;
&lt;th&gt;Cloudflare R2&lt;/th&gt;
&lt;th&gt;Backblaze B2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Egress to internet&lt;/td&gt;
&lt;td&gt;Per GB, the dominant cost at scale&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Free up to a multiple of stored data, per GB above that&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Egress to own CDN&lt;/td&gt;
&lt;td&gt;Free to CloudFront&lt;/td&gt;
&lt;td&gt;N/A (Cloudflare CDN is in front)&lt;/td&gt;
&lt;td&gt;Free to Cloudflare via the Bandwidth Alliance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage rate&lt;/td&gt;
&lt;td&gt;Lowest of the three at list&lt;/td&gt;
&lt;td&gt;Middle&lt;/td&gt;
&lt;td&gt;Lowest of the three at list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operations&lt;/td&gt;
&lt;td&gt;Billed, cheap&lt;/td&gt;
&lt;td&gt;Billed, Class A noticeably pricier than Class B&lt;/td&gt;
&lt;td&gt;Billed, with a free daily allowance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 API compatibility&lt;/td&gt;
&lt;td&gt;The reference&lt;/td&gt;
&lt;td&gt;High, with gaps&lt;/td&gt;
&lt;td&gt;High, with gaps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage classes / lifecycle&lt;/td&gt;
&lt;td&gt;Deep (IA, Glacier tiers)&lt;/td&gt;
&lt;td&gt;Shallow&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regional control&lt;/td&gt;
&lt;td&gt;Explicit regions&lt;/td&gt;
&lt;td&gt;Location hints, not hard regions&lt;/td&gt;
&lt;td&gt;Explicit regions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The uncomfortable part of that table for AWS shops: S3's cheap storage is real, and its egress is what funds it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; compare providers on egress-to-storage ratio for your actual traffic, because that ratio is the only axis where the three differ by more than a small multiple.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is S3 still the right call?
&lt;/h2&gt;

&lt;p&gt;More often than the "R2 has no egress fees" discourse suggests.&lt;/p&gt;

&lt;p&gt;Keep uploads on S3 when your bytes mostly &lt;em&gt;don't&lt;/em&gt; leave AWS — a bucket read by ECS tasks, Lambda, Athena, or SageMaker in the same region pays no internet egress at all. Same-region service-to-service traffic is the case S3's pricing is built for, and moving that bucket to another provider means you now pay for the round trip in latency and, on the way in, in cross-cloud transfer.&lt;/p&gt;

&lt;p&gt;Keep it on S3 too when you need what only S3 has: lifecycle transitions into archival tiers, Object Lock for compliance retention, bucket-level replication rules, event notifications wired into the rest of AWS, or the long tail of tooling that assumes real S3 semantics. If your compliance story includes "objects are immutable for seven years," S3 is the one with the mature answer and the auditor-friendly paper trail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; if your read traffic terminates inside AWS, egress-free storage saves you nothing and costs you integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  When do egress fees justify moving?
&lt;/h2&gt;

&lt;p&gt;Run this before you argue about it. Pull a month of CloudFront or S3 access logs and get bytes-out against bytes-stored:&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;-- Athena over S3 server access logs: monthly bytes served, top prefixes&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;regexp_extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'^([^/]+)/'&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="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;requests&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;bytessent&lt;/span&gt;&lt;span class="p"&gt;)&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="mi"&gt;0&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;gb_sent&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;s3_access_logs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;operation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'REST.GET.OBJECT'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;parse_datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestdatetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'dd/MMM/yyyy:HH:mm:ss Z'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="k"&gt;current_date&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;'30'&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&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;gb_sent&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;Divide monthly GB sent by GB stored. My rough decision line, from doing this on several small products:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Under ~2x&lt;/strong&gt; — egress is noise. Stay where you are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2x to ~10x&lt;/strong&gt; — worth putting a CDN in front and re-measuring. Caching at the edge often deletes the problem more cheaply than a migration does, and S3-to-CloudFront origin fetches don't incur internet egress.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over ~10x, with a low cache hit ratio&lt;/strong&gt; (large unique files, signed one-time downloads, ML artifacts) — this is where zero-egress pricing genuinely changes the bill, because a CDN can't cache what's never requested twice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last category is the honest answer to "when should I move." If you want zero-egress object storage with an S3-compatible API and a CDN already in the same network path, Cloudflare R2 is the one that removes the bandwidth line from the bill entirely. If you want the cheapest at-rest storage for large archives with generous but bounded free egress, Backblaze B2 is the one that keeps cold data cheap without archival-tier retrieval games.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; a CDN in front of S3 beats a migration for cacheable traffic; migration wins when every download is unique.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the AWS SDK break against R2 and B2?
&lt;/h2&gt;

&lt;p&gt;This is the part that eats an afternoon. The AWS SDK for JavaScript v3 started sending flexible checksums (CRC32) by default on requests, and S3-compatible providers that haven't implemented those headers reject them. You get something unhelpful like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NotImplemented: Header 'x-amz-checksum-crc32' with value '...' not implemented
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or a bare &lt;code&gt;501&lt;/code&gt; on what was, five minutes ago, a working &lt;code&gt;PutObject&lt;/code&gt;. Nothing about your credentials or bucket changed — the SDK's defaults did. The fix is to make checksums 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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;S3Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PutObjectCommand&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;@aws-sdk/client-s3&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;s3&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;S3Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://&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;R2_ACCOUNT_ID&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.r2.cloudflarestorage.com`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;accessKeyId&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;R2_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;secretAccessKey&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;R2_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// Required for non-AWS S3-compatible endpoints that reject CRC32 headers&lt;/span&gt;
  &lt;span class="na"&gt;requestChecksumCalculation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;WHEN_REQUIRED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;responseChecksumValidation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;WHEN_REQUIRED&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;s3&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PutObjectCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;uploads&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;u/42/avatar.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ContentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/png&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;Two more compatibility traps in the same family. Presigned PUT URLs generated by a checksum-happy SDK embed a signed header the browser never sends, so the upload fails signature validation at the edge rather than in your code — generate presigned URLs with the same &lt;code&gt;WHEN_REQUIRED&lt;/code&gt; client. And CORS is configured per provider, not per SDK: a browser upload that works in &lt;code&gt;curl&lt;/code&gt; and fails in the browser is a bucket CORS rule, every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; "S3-compatible" means the API surface, not the SDK defaults — pin checksum behavior explicitly the moment you point an AWS SDK at a non-AWS endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a low-regret setup look like?
&lt;/h2&gt;

&lt;p&gt;Keep the provider swappable so this decision stays cheap to revisit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Talk to storage through one module that exposes &lt;code&gt;put&lt;/code&gt;, &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;presignPut&lt;/code&gt;, &lt;code&gt;presignGet&lt;/code&gt;, &lt;code&gt;delete&lt;/code&gt;. Never let a bucket name or an endpoint URL leak into request handlers.&lt;/li&gt;
&lt;li&gt;Store the object key in your database, never a full URL. URLs change when providers do; keys don't.&lt;/li&gt;
&lt;li&gt;Serve through your own domain via a CDN from day one. Then a provider change is an origin change, not a link-rot event across every row you've ever written.&lt;/li&gt;
&lt;li&gt;Tag or prefix by workload (&lt;code&gt;uploads/&lt;/code&gt;, &lt;code&gt;exports/&lt;/code&gt;, &lt;code&gt;backups/&lt;/code&gt;) so the query above can tell you &lt;em&gt;which&lt;/em&gt; workload is generating egress. Aggregate bucket totals hide the one prefix that's actually the bill.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Honest drawbacks, since every tool here has one: R2's operation pricing punishes chatty small-object workloads and its lifecycle/archival story is thin, B2's free egress is bounded by your stored volume so a small bucket with viral traffic still pays, and S3 charges you for the privilege of leaving.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Is Cloudflare R2 actually free to download from?&lt;/strong&gt;&lt;br&gt;
Egress to the internet is not billed, but storage and per-operation charges are. A workload with millions of tiny reads and writes can cost more on R2 than on S3 despite paying nothing for bandwidth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use the AWS SDK with Cloudflare R2 or Backblaze B2?&lt;/strong&gt;&lt;br&gt;
Yes — both expose S3-compatible APIs and work with the standard AWS SDKs by overriding the endpoint. On AWS SDK for JavaScript v3 you must also set &lt;code&gt;requestChecksumCalculation&lt;/code&gt; and &lt;code&gt;responseChecksumValidation&lt;/code&gt; to &lt;code&gt;WHEN_REQUIRED&lt;/code&gt;, or uploads fail with a not-implemented checksum header error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I know if S3 egress fees are worth migrating away from?&lt;/strong&gt;&lt;br&gt;
Divide monthly GB served by GB stored. Under about 2x, egress is noise; above about 10x with poor CDN cacheability, zero-egress providers meaningfully change the bill.&lt;/p&gt;

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

&lt;p&gt;If your objects are read mostly by other AWS services, stay on S3 — you're not paying internet egress and you'd lose lifecycle tiers, Object Lock, and event integration for nothing. If your objects are served to browsers and cache well, put a CDN in front of S3 and re-measure before migrating; that alone resolves most egress complaints. If every download is unique and large enough that caching can't help, R2's zero-egress pricing is the one that removes the line item, and B2 is the better fit when cold storage volume dominates and your egress stays within its free multiple. Whatever you pick, keep object keys — not URLs — in your database, so the next re-measurement is a config change instead of a migration.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/sentry-alternatives-when-error-tracking-bills-grow-faster-than-your-user-base-4pf5"&gt;Sentry Alternatives: When Error Tracking Bills Grow Faster Than Your User Base&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/self-hosting-your-first-llm-what-the-tutorials-skip-about-gpu-memory-50pc"&gt;Self-Hosting Your First LLM: What the Tutorials Skip About GPU Memory&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/webhook-reliability-101-retries-idempotency-and-the-bugs-that-bite-at-2-am-3e5e"&gt;Webhook Reliability 101: Retries, Idempotency, and the Bugs That Bite at 2 A.M.&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>cloud</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>Before You Set plan_cache_mode, Write the Regression Test That Proves It Worked</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sat, 22 Aug 2026 23:23:25 +0000</pubDate>
      <link>https://dev.to/libme/before-you-set-plancachemode-write-the-regression-test-that-proves-it-worked-23l6</link>
      <guid>https://dev.to/libme/before-you-set-plancachemode-write-the-regression-test-that-proves-it-worked-23l6</guid>
      <description>&lt;p&gt;A single fast query on a single tenant does not prove you fixed a Postgres plan-caching problem. Postgres decides between a custom plan and a cached generic plan by comparing &lt;em&gt;average estimated cost across executions&lt;/em&gt;, so a change that rescues your smallest tenant can quietly add planning work to the tenant that generates 95% of your traffic. The test that actually settles it warms one connection past the plan switch, replays low, medium, and high selectivity parameters, and asserts latency, rows, buffers, and plan shape — not just wall-clock on the one case that hurt.&lt;/p&gt;

&lt;p&gt;A reader made this point on an earlier post of mine about prepared statements, and it's the part I had underbuilt. Below is the harness I use now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why doesn't one fast query prove the fix?
&lt;/h2&gt;

&lt;p&gt;Postgres builds a custom plan (re-planned with your actual parameter values) for the first five executions of a prepared statement, averages their estimated cost, and from the sixth execution onward compares that average against a generic plan built with no knowledge of the values. If the generic plan doesn't look more expensive, it locks in.&lt;/p&gt;

&lt;p&gt;Two consequences fall out of that mechanism, and both break naive tests:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The decision is made on an average.&lt;/strong&gt; Whether a generic plan wins depends on which parameters warmed the statement. Warm it with your rare, highly selective tenant and you may get a different outcome than production, where 95% of executions carry the dominant value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forcing custom plans is not free.&lt;/strong&gt; &lt;code&gt;plan_cache_mode = force_custom_plan&lt;/code&gt; means re-planning on every execution. For a query planned in 0.4 ms and executed in 900 ms, that's noise. For a short OLTP query planned in 3 ms and executed in 1.2 ms, you just tripled its cost — for the workload that dominates your CPU.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Takeaway: the fix and the regression are the same change viewed from two different parameter distributions, so the test has to carry both.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you make a test session use the cached generic plan?
&lt;/h2&gt;

&lt;p&gt;The hard part is that plan caching is &lt;em&gt;connection-local state&lt;/em&gt;. Open a fresh connection, run the query once, and you measure a custom plan — the exact thing that never reproduces the bug. You have to warm past the switch inside the same session, using the parameter distribution production actually sends.&lt;/p&gt;

&lt;p&gt;The most direct way to do that is a SQL-level prepared statement, which puts the counters somewhere you can read:&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;PREPARE&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;text&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;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;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_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="k"&gt;AND&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;'pending'&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;created_at&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;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- warm with the DOMINANT parameter, not the pathological one&lt;/span&gt;
&lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'bigcorp'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;-- x6&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;custom_plans&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;generic_plans&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_prepared_statements&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'q'&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;pg_prepared_statements&lt;/code&gt; exposes &lt;code&gt;custom_plans&lt;/code&gt; and &lt;code&gt;generic_plans&lt;/code&gt; counters in PostgreSQL 14 and later, and they are the only honest answer to "did my warmup take?" If &lt;code&gt;generic_plans&lt;/code&gt; is still 0 after warmup, your test is measuring a custom plan and every assertion below it is meaningless.&lt;/p&gt;

&lt;p&gt;What tripped me up the first time: the plan cache is keyed to the statement, and &lt;code&gt;EXPLAIN (ANALYZE) SELECT …&lt;/code&gt; is a &lt;em&gt;different statement&lt;/em&gt; from &lt;code&gt;SELECT …&lt;/code&gt;. If you warm the raw query and then inspect with an EXPLAIN-wrapped copy, you are inspecting a freshly planned custom plan and will conclude, wrongly, that everything is fine. Prepare once, then EXPLAIN the &lt;code&gt;EXECUTE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Takeaway: assert on &lt;code&gt;generic_plans &amp;gt; 0&lt;/code&gt; before you assert on anything else, because a warmup that silently failed produces a green test that proves nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building fixtures from the real parameter distribution
&lt;/h2&gt;

&lt;p&gt;Three buckets is usually enough, drawn from your actual data rather than invented:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bucket&lt;/th&gt;
&lt;th&gt;How to pick it&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;High selectivity (rare value)&lt;/td&gt;
&lt;td&gt;A parameter matching a tiny fraction of rows&lt;/td&gt;
&lt;td&gt;The original pathology — generic plan sequential-scans for a 400-row answer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median&lt;/td&gt;
&lt;td&gt;The value nearest the median row count per key&lt;/td&gt;
&lt;td&gt;Drift in the middle of the distribution as data grows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dominant (hot value)&lt;/td&gt;
&lt;td&gt;The value your logs show most often, usually also the largest&lt;/td&gt;
&lt;td&gt;Regression from the fix — added planning cost on the hot path&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pull them with one query rather than guessing:&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;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;rows&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
 &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;
 &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This only works against a database whose statistics resemble production. A uniformly seeded test dataset cannot reproduce a skew bug, because with uniform data the generic plan is genuinely correct. If you can't run against a production-shaped copy, this whole class of test is theater — restore a sanitized dump instead.&lt;/p&gt;

&lt;p&gt;Takeaway: fixtures chosen from &lt;code&gt;count(*) GROUP BY&lt;/code&gt; are evidence; fixtures chosen because they looked representative are assumptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should the test assert besides latency?
&lt;/h2&gt;

&lt;p&gt;Latency alone is flaky under CI noise. Assert the physical work and the plan shape too — those are stable enough to gate a merge on.&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="c1"&gt;# plan_stability_test.py  (psycopg 3)
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;

&lt;span class="n"&gt;DSN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql:///app&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# same role and database as the app
&lt;/span&gt;
&lt;span class="n"&gt;STMT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
SELECT id, status, created_at
  FROM events
 WHERE tenant_id = $1 AND status = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pending&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;
 ORDER BY created_at DESC
 LIMIT 50
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="c1"&gt;# label, parameter, max_ms, max_shared_blocks, expected plan family
&lt;/span&gt;&lt;span class="n"&gt;BUCKETS&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rare&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;acme&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="mf"&gt;15.0&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Limit[IndexScan(events_tenant_status_created_idx)]&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;median&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;midco&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="mf"&gt;60.0&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Limit[IndexScan(events_tenant_status_created_idx)]&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;dominant&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;bigcorp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;250.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Limit[IndexScan(events_tenant_status_created_idx)]&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;plan_family&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Node-type fingerprint: ignores row counts, catches plan-shape changes.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Node Type&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &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="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Index Name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;+=&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;(&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Index Name&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;kids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;plan_family&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&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;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;node&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;Plans&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="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;kids&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;kids&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;explain_execute&lt;/span&gt;&lt;span class="p"&gt;(&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;param&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EXPLAIN (ANALYZE, BUFFERS, SETTINGS, FORMAT JSON) EXECUTE q({})&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&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="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;param&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;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchone&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;0&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;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DSN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;autocommit&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;as&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&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;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&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;PREPARE q(text) AS &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;STMT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# warm past the switch using the dominant parameter
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&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="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EXECUTE q({})&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bigcorp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
            &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&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;SELECT custom_plans, generic_plans &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FROM pg_prepared_statements WHERE name = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;q&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;custom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;generic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchone&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;warmup: custom_plans=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;custom&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; generic_plans=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;generic&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&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;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;param&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_blocks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;BUCKETS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;runs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;explain_execute&lt;/span&gt;&lt;span class="p"&gt;(&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;param&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;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
            &lt;span class="n"&gt;plans&lt;/span&gt; &lt;span class="o"&gt;=&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Plan&lt;/span&gt;&lt;span class="sh"&gt;"&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Execution Time&lt;/span&gt;&lt;span class="sh"&gt;"&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;)[&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;runs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plans&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Shared Hit Blocks&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="n"&gt;plans&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Shared Read Blocks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;family&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;plan_family&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plans&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;8.2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ms  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; blocks  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;family&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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;ms&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;max_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;ms &amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;max_ms&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;ms&lt;/span&gt;&lt;span class="sh"&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;blocks&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;max_blocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; blocks &amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;max_blocks&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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;family&lt;/span&gt; &lt;span class="o"&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;failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: plan family &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;family&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; != &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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;failures&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;SystemExit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PLAN REGRESSION&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;failures&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&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;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fingerprint matters more than it looks. Asserting on raw EXPLAIN text fails every time a row estimate shifts; asserting on node types plus index name fails only when the plan genuinely changes shape — which is exactly the event you want a build to stop for. If &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;'s per-row timing overhead distorts your latency numbers, run the same harness with &lt;code&gt;TIMING OFF&lt;/code&gt; and gate on buffers and plan family alone.&lt;/p&gt;

&lt;p&gt;Takeaway: buffers and node types are the assertions that survive a noisy CI runner; latency is the one you keep loose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which knob do you actually reach for?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Reach for it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;plan_cache_mode = auto&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;Five custom plans, then compares&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Data is roughly uniform across the parameter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;plan_cache_mode = force_custom_plan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Re-plans every execution&lt;/td&gt;
&lt;td&gt;Planning time on every call&lt;/td&gt;
&lt;td&gt;Skew is severe and execution dominates planning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set it per-statement via &lt;code&gt;SET LOCAL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Scopes the override to one transaction&lt;/td&gt;
&lt;td&gt;Same, but bounded&lt;/td&gt;
&lt;td&gt;Only one or two queries are pathological&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disable server-side prepares in the driver&lt;/td&gt;
&lt;td&gt;No plan cache at all&lt;/td&gt;
&lt;td&gt;Loses parse-time reuse everywhere&lt;/td&gt;
&lt;td&gt;You need a fast, blunt rollback&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Scope beats globals here. &lt;code&gt;SET LOCAL plan_cache_mode = force_custom_plan&lt;/code&gt; inside the transaction that runs the skewed query fixes that query and leaves the rest of your workload on the default — and it shows up in &lt;code&gt;EXPLAIN (SETTINGS)&lt;/code&gt; output, so your harness can verify the setting was actually in effect rather than assume it.&lt;/p&gt;

&lt;p&gt;For continuous coverage between CI runs, the in-tree &lt;code&gt;auto_explain&lt;/code&gt; module logs real plans for slow executions with no extra infrastructure, at the price of log volume you have to manage. If you want plan history retained and compared over time without building that yourself, pganalyze is the managed option that tracks per-query plan changes, with the usual trade-off of another vendor in your data path.&lt;/p&gt;

&lt;p&gt;Takeaway: a per-transaction &lt;code&gt;SET LOCAL&lt;/code&gt; you can verify is safer than a global GUC you have to remember.&lt;/p&gt;

&lt;h2&gt;
  
  
  When "same SQL" isn't the same query
&lt;/h2&gt;

&lt;p&gt;The last thing to diff is the session contract, because two connections can run byte-identical SQL against different relations:&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;current_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;session_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'search_path'&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;search_path&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;setting&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_settings&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&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;'plan_cache_mode'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'row_security'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'work_mem'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'TimeZone'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'random_page_cost'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'effective_cache_size'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'jit'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'enable_seqscan'&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;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three of these bite regularly. A different &lt;code&gt;search_path&lt;/code&gt; resolves an unqualified table name to a different schema. Row-level security applies to the app role but is bypassed by the table owner, so a test run as owner sees a table with no policy predicates attached — a different plan by construction. And &lt;code&gt;TimeZone&lt;/code&gt; changes what a &lt;code&gt;date_trunc&lt;/code&gt; or range predicate actually matches, which changes selectivity.&lt;/p&gt;

&lt;p&gt;Run the harness as the application role, against the application database, or you are testing a query that doesn't exist in production.&lt;/p&gt;

&lt;p&gt;Takeaway: connect as the app role, or your plan test is measuring a query production never runs.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;How do I check if Postgres is using a generic or custom plan?&lt;/strong&gt;&lt;br&gt;
Query &lt;code&gt;pg_prepared_statements&lt;/code&gt; for the &lt;code&gt;custom_plans&lt;/code&gt; and &lt;code&gt;generic_plans&lt;/code&gt; columns, available in PostgreSQL 14 and later. For a statement you prepared with &lt;code&gt;PREPARE&lt;/code&gt;, &lt;code&gt;EXPLAIN (ANALYZE) EXECUTE stmt(...)&lt;/code&gt; shows the plan being used; a generic plan displays &lt;code&gt;$1&lt;/code&gt; in place of the parameter value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does plan_cache_mode = force_custom_plan hurt performance?&lt;/strong&gt;&lt;br&gt;
Yes, for short queries. It re-plans on every execution, so a query that plans in 3 ms and runs in 1 ms becomes roughly four times more expensive. Use &lt;code&gt;SET LOCAL&lt;/code&gt; to scope it to the transaction running the skewed query instead of setting it globally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is my query fast in psql but slow from the application?&lt;/strong&gt;&lt;br&gt;
psql sends literal values, so the planner uses column statistics for that specific value. Your driver sends the query as a prepared statement with bound parameters, and after five executions Postgres may switch to a generic plan built without knowledge of those values — which is catastrophic when your data is skewed.&lt;/p&gt;

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

&lt;p&gt;If you're about to change a plan-caching setting, spend the extra hour on the harness rather than the extra hour re-reading &lt;code&gt;EXPLAIN&lt;/code&gt; output. Warm one connection past the switch with the dominant parameter, replay rare/median/dominant fixtures pulled from a real &lt;code&gt;GROUP BY&lt;/code&gt;, and assert on plan family and buffers with a loose latency bound. Verify &lt;code&gt;generic_plans &amp;gt; 0&lt;/code&gt; before trusting a single number below it, and run as the application role so RLS and &lt;code&gt;search_path&lt;/code&gt; match production. Teams with one pathological query should reach for &lt;code&gt;SET LOCAL plan_cache_mode&lt;/code&gt;; teams that keep rediscovering this should keep the harness in CI, because tenant distributions drift and the bug comes back on its own.&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/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;li&gt;&lt;a href="https://dev.to/libme/ship-a-production-rag-chatbot-in-a-weekend-with-claude-pgvector-and-fastapi-3aeo"&gt;Ship a Production RAG Chatbot in a Weekend with Claude, pgvector, and FastAPI&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>testing</category>
      <category>performance</category>
    </item>
    <item>
      <title>Your Cache Expired and the Database Spiked: Four Fixes for Cache Stampedes</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sat, 22 Aug 2026 14:52:45 +0000</pubDate>
      <link>https://dev.to/libme/your-cache-expired-and-the-database-spiked-four-fixes-for-cache-stampedes-3fh5</link>
      <guid>https://dev.to/libme/your-cache-expired-and-the-database-spiked-four-fixes-for-cache-stampedes-3fh5</guid>
      <description>&lt;p&gt;When a hot cache key expires, every in-flight request misses at the same instant and all of them recompute the same value against the same database. That is a cache stampede, and it shows up as a latency cliff on a fixed interval rather than a gradual degradation. The fixes, in the order I would apply them: jitter your TTLs, add single-flight locking so only one worker recomputes, serve stale while revalidating in the background, and — if you need the last bit of smoothness — recompute probabilistically before expiry.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a cache stampede actually look like in the logs?
&lt;/h2&gt;

&lt;p&gt;The tell is periodicity. Not "the database is slow under load" but "the database is fine, then pinned, then fine again, every five minutes." If your hot key TTL is 300 seconds and your p99 graph has teeth exactly 300 seconds apart, you are looking at your own TTL, not a traffic pattern.&lt;/p&gt;

&lt;p&gt;On the application side you get a burst of timeouts against one endpoint, and the errors sit downstream of the cache rather than in it. In a Postgres-backed service the burst reads as connection pressure — pool checkout timeouts, or the server refusing connections outright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL: sorry, too many clients already
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a Go or Node service the same event surfaces as a wall of context-deadline errors that all reference the same query. Redis itself looks healthy throughout: hit rate dips for a second or two, memory is flat, &lt;code&gt;INFO commandstats&lt;/code&gt; shows nothing unusual. That combination — healthy cache, spiking origin, fixed interval — is the fingerprint.&lt;/p&gt;

&lt;p&gt;Log the cache-miss path with the key name and count misses per key per second. A stampede is not "many misses"; it is many misses on &lt;em&gt;one&lt;/em&gt; key inside one recompute window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why doesn't adding cache capacity or raising the hit rate fix it?
&lt;/h2&gt;

&lt;p&gt;Because hit rate is the wrong number. Suppose one key serves 500 requests per second and takes 200 ms to recompute. The moment it expires, roughly 100 requests arrive before the first recompute finishes, and each sees an empty slot and starts its own. Your steady-state hit rate can be 99.8% and the incident still happens, because the damage is done by concurrency inside a 200 ms window, not by the volume of misses over an hour.&lt;/p&gt;

&lt;p&gt;This is also why a bigger Redis instance, a longer TTL, or a warm-up job on deploy do not help. A longer TTL makes stampedes rarer and &lt;em&gt;worse&lt;/em&gt; — more accumulated traffic per expiry, and a colder recompute when it lands. Warm-up jobs are counterproductive if they populate thousands of keys in a loop with identical TTLs, since you have just synchronized all of them to expire in the same second.&lt;/p&gt;

&lt;p&gt;The takeaway: a stampede is a concurrency-control bug in your cache-miss path, not a capacity problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: Jitter every TTL, without exception
&lt;/h2&gt;

&lt;p&gt;This is a one-line change and it dissolves the synchronized-expiry class of the problem entirely. Never write a fixed TTL:&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;random&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ttl_with_jitter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spread&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# 60s base -&amp;gt; a value uniformly in [54, 66]
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_seconds&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;spread&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;spread&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten percent spread on a 60-second TTL smears a bulk warm-up across a 12-second window instead of one instant. It does nothing for a single very hot key — that key still has one expiry moment, and everyone still piles into it — which is why jitter is necessary but never sufficient.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: Single-flight, so only one worker recomputes
&lt;/h2&gt;

&lt;p&gt;Single-flight means the first request to notice the miss takes a short-lived lock and does the work; everyone else either waits for it or serves something else. In Redis the lock is &lt;code&gt;SET key value NX EX seconds&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;json&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;random&lt;/span&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fresh_for&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep_stale_for&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fresh_for&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;entry&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;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fresh_until&lt;/span&gt;&lt;span class="sh"&gt;"&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="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;fresh&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;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;keep_stale_for&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;get_cached&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fresh_for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep_stale_for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lock_ttl&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;lock_key&lt;/span&gt; &lt;span class="o"&gt;=&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;lock:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;raw&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&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;raw&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&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;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;time&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;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fresh_until&lt;/span&gt;&lt;span class="sh"&gt;"&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;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="c1"&gt;# Stale but usable: one caller refreshes, everyone else serves stale.
&lt;/span&gt;        &lt;span class="k"&gt;if&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;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lock_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nx&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="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lock_ttl&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="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="nf"&gt;_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fresh_for&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep_stale_for&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;value&lt;/span&gt;
            &lt;span class="k"&gt;finally&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;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lock_key&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;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# Cold miss: nothing to serve, so losers wait briefly for the winner.
&lt;/span&gt;    &lt;span class="k"&gt;if&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;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lock_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nx&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="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lock_ttl&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="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fresh_for&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep_stale_for&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;value&lt;/span&gt;
        &lt;span class="k"&gt;finally&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;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lock_key&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;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&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;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.05&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="n"&gt;r&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="n"&gt;key&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;raw&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details bite people here. The lock needs its own TTL, or a worker that gets OOM-killed mid-recompute leaves the key permanently unrefreshable. And &lt;code&gt;r.delete(lock_key)&lt;/code&gt; in the &lt;code&gt;finally&lt;/code&gt; block is technically unsafe: if the recompute overruns &lt;code&gt;lock_ttl&lt;/code&gt;, the lock has already expired and been taken by someone else, so you just deleted &lt;em&gt;their&lt;/em&gt; lock. The correct version stores a random token in the lock and deletes it with a small Lua script that compares the token first. For a 10-second lock around a 200 ms query the exposure is small — but it is why single-flight code that "works fine" in staging occasionally double-computes in production.&lt;/p&gt;

&lt;p&gt;In Go, do not write this by hand: &lt;code&gt;golang.org/x/sync/singleflight&lt;/code&gt; collapses duplicate calls, with the caveat that it is per-process, so ten pods still produce ten recomputes.&lt;/p&gt;

&lt;p&gt;Single-flight turns N concurrent recomputes into one, but the N-1 losers on a cold miss are still waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: Stale-while-revalidate, which is the one that removes the cliff
&lt;/h2&gt;

&lt;p&gt;The code above is already stale-while-revalidate: the entry carries a &lt;em&gt;logical&lt;/em&gt; freshness timestamp and the Redis TTL is set much longer. Between &lt;code&gt;fresh_until&lt;/code&gt; and physical expiry, readers get an instant cached response while exactly one worker refreshes in the background. Nobody blocks, so there is no cliff — the origin sees one query per refresh interval regardless of traffic.&lt;/p&gt;

&lt;p&gt;The cost is correctness: you knowingly serve data up to &lt;code&gt;keep_stale_for&lt;/code&gt; seconds old when refreshes fail. For a dashboard aggregate or a search facet count, that is free. For anything a user just wrote and expects to see, it is not — those keys need explicit invalidation on write and stale windows measured in seconds.&lt;/p&gt;

&lt;p&gt;At the HTTP layer you may not need application code at all. Varnish coalesces concurrent requests for the same object by default and can serve stale content while fetching a fresh copy; nginx does the narrower version with &lt;code&gt;proxy_cache_lock on;&lt;/code&gt; plus &lt;code&gt;proxy_cache_use_stale updating;&lt;/code&gt;. Both apply only to full HTTP responses keyed by URL, so neither helps the internal fragment caches that usually cause this.&lt;/p&gt;

&lt;p&gt;The rule of thumb: if you can name a tolerable staleness window for a key, stale-while-revalidate is strictly better than locking alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 4: Probabilistic early recomputation
&lt;/h2&gt;

&lt;p&gt;If you cannot serve stale — say the value is a signed token or a count that must never go backwards — each reader can independently decide to refresh &lt;em&gt;slightly early&lt;/em&gt;, with probability rising as expiry approaches. This is the XFetch approach from the 2015 paper on optimal probabilistic cache stampede prevention, and it is about six lines:&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;math&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;should_refresh_early&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta_seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ttl_remaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;beta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;delta_seconds = how long the last recompute took.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;delta_seconds&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;beta&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;math&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="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&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;ttl_remaining&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store &lt;code&gt;delta&lt;/code&gt; alongside the value when you write it. Expensive values start volunteering for refresh earlier; cheap ones wait. Raising &lt;code&gt;beta&lt;/code&gt; above 1.0 makes refreshes more eager. I have shipped this, but it is the fix I reach for last: it adds a tunable nobody will remember the meaning of in six months, and it only helps when stale-serving is off the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which fix should you actually apply?
&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;Origin load at expiry&lt;/th&gt;
&lt;th&gt;Reader waits?&lt;/th&gt;
&lt;th&gt;Serves stale?&lt;/th&gt;
&lt;th&gt;Main cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Jittered TTL&lt;/td&gt;
&lt;td&gt;Unchanged per key&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Nothing — always do it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single-flight lock&lt;/td&gt;
&lt;td&gt;1 recompute&lt;/td&gt;
&lt;td&gt;Yes, on cold miss&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Lock-expiry edge cases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stale-while-revalidate&lt;/td&gt;
&lt;td&gt;1 recompute&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Bounded staleness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probabilistic early refresh&lt;/td&gt;
&lt;td&gt;1 recompute, before expiry&lt;/td&gt;
&lt;td&gt;Rarely&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;An opaque tuning knob&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For most services the answer is the middle two together — exactly what the code above does: single-flight on cold misses, stale-while-revalidate on warm ones, jitter on every write.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;What is a cache stampede?&lt;/strong&gt;&lt;br&gt;
A cache stampede (also called a thundering herd or dog-piling) happens when a cached value expires and many concurrent requests miss simultaneously, each recomputing the same value against the origin. The result is a burst of identical queries that can saturate the database even though overall hit rate stays high.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I prevent a Redis cache stampede?&lt;/strong&gt;&lt;br&gt;
Use &lt;code&gt;SET lock:&amp;lt;key&amp;gt; &amp;lt;token&amp;gt; NX EX &amp;lt;seconds&amp;gt;&lt;/code&gt; so only one worker recomputes a given key, store the entry with a logical freshness timestamp shorter than its Redis TTL so other readers serve slightly stale data instead of blocking, and add ±10% jitter to every TTL so bulk-populated keys never expire in the same second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does increasing the TTL fix a cache stampede?&lt;/strong&gt;&lt;br&gt;
No. A longer TTL makes stampedes less frequent but more severe, because more traffic accumulates behind each expiry and the recompute is colder. The concurrency of the miss is what hurts, not how often it occurs.&lt;/p&gt;

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

&lt;p&gt;If you have a periodic latency cliff and a healthy-looking cache, add jitter today and single-flight this week. If the value tolerates being a few seconds old — most aggregates, counts, and rendered fragments do — go straight to stale-while-revalidate, the only option here where readers never wait on a recompute. Reserve probabilistic early refresh for values that genuinely cannot be served stale, and log misses per key so the next incident takes one query to diagnose instead of an hour.&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/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/cost-attribution-before-cost-optimization-how-to-find-out-what-is-actually-charging-you-5086"&gt;Cost Attribution Before Cost Optimization: How to Find Out What Is Actually Charging You&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>performance</category>
      <category>database</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Rate Limiting Your Own API: Should the Counter Live in Redis, Postgres, or at the Edge?</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Fri, 21 Aug 2026 15:02:57 +0000</pubDate>
      <link>https://dev.to/libme/rate-limiting-your-own-api-should-the-counter-live-in-redis-postgres-or-at-the-edge-39db</link>
      <guid>https://dev.to/libme/rate-limiting-your-own-api-should-the-counter-live-in-redis-postgres-or-at-the-edge-39db</guid>
      <description>&lt;p&gt;If your rate limiter keeps its counter in process memory, your advertised limit is a lie as soon as you run more than one instance — four replicas means roughly four times the traffic gets through. Move the counter somewhere all instances can see: Redis if you need low latency at real volume, Postgres if you already run one and your traffic is modest, the edge if what you're stopping is volumetric abuse rather than per-customer quota. The hard part isn't the algorithm; it's deciding what happens when the counter store itself is down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom: a 100 req/min limit that lets through 400
&lt;/h2&gt;

&lt;p&gt;The bug report reads like this: a customer on a "100 requests per minute" plan is clearly doing 300-plus and never sees a 429. Nothing looks broken — the limiter is running, it returns &lt;code&gt;X-RateLimit-Remaining&lt;/code&gt;, and if you hammer it locally it works perfectly. Then you check how many app instances are running.&lt;/p&gt;

&lt;p&gt;Most drop-in middleware defaults to an in-memory store — &lt;code&gt;express-rate-limit&lt;/code&gt;, for instance, ships with a memory store by default and its docs are explicit that this doesn't work across multiple processes. Each process keeps its own independent bucket. With four containers behind a load balancer, a caller round-robins across four separate 100-request budgets. Nothing errors. The number is just wrong.&lt;/p&gt;

&lt;p&gt;There's a quieter version of the same bug: one instance, but the process restarts on every deploy. Counters reset, and anyone who times their burst around a deploy gets a fresh budget. Same root cause — the counter's lifetime is tied to the process, and the process is not the unit your limit is defined over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your rate limit is defined per customer, the counter has to live somewhere that outlives and spans your processes.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where can the counter actually live?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;th&gt;Accuracy across instances&lt;/th&gt;
&lt;th&gt;Added latency per request&lt;/th&gt;
&lt;th&gt;Main failure mode&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;In-process memory&lt;/td&gt;
&lt;td&gt;None (per replica)&lt;/td&gt;
&lt;td&gt;~0&lt;/td&gt;
&lt;td&gt;Silently multiplies your limit&lt;/td&gt;
&lt;td&gt;Single-process tools, local dev&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis (or Redis-compatible)&lt;/td&gt;
&lt;td&gt;Exact, atomic via Lua&lt;/td&gt;
&lt;td&gt;Sub-millisecond on the same network&lt;/td&gt;
&lt;td&gt;Redis down → fail-open or fail-closed decision&lt;/td&gt;
&lt;td&gt;Per-key quotas at real volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres&lt;/td&gt;
&lt;td&gt;Exact, atomic via upsert&lt;/td&gt;
&lt;td&gt;A write per request, plus pool contention&lt;/td&gt;
&lt;td&gt;Write amplification, autovacuum load&lt;/td&gt;
&lt;td&gt;Modest traffic, no new dependency wanted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge / CDN / API gateway&lt;/td&gt;
&lt;td&gt;Exact enough, but scoped to what the edge can see&lt;/td&gt;
&lt;td&gt;None to your origin&lt;/td&gt;
&lt;td&gt;Can't see app-level identity or plan tier&lt;/td&gt;
&lt;td&gt;Volumetric abuse, IP floods&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These aren't mutually exclusive. The sane end state for most teams is two layers: something blunt at the edge that keeps a flood off your origin, and something identity-aware in the app that enforces the quota you actually sell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick the location by what the limit is keyed on: IP-shaped limits belong at the edge, plan-shaped limits belong where your app knows who the caller is.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you implement a token bucket in Redis without race conditions?
&lt;/h2&gt;

&lt;p&gt;The naive version — &lt;code&gt;GET&lt;/code&gt;, check, &lt;code&gt;SET&lt;/code&gt; — is a read-modify-write race, and under exactly the concurrency you're trying to limit, it's wrong. Do the whole thing in one Lua script so it executes atomically on the server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- KEYS[1]  = bucket key, e.g. "rl:tenant_42"&lt;/span&gt;
&lt;span class="c1"&gt;-- ARGV[1]  = capacity (max burst)&lt;/span&gt;
&lt;span class="c1"&gt;-- ARGV[2]  = refill rate, tokens per second&lt;/span&gt;
&lt;span class="c1"&gt;-- ARGV[3]  = current time in ms (from the caller)&lt;/span&gt;
&lt;span class="c1"&gt;-- ARGV[4]  = cost of this request, usually 1&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;KEYS&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="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&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="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;refill&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&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="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&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="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;state&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="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'HMGET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'tokens'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ts'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&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="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&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;if&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;capacity&lt;/span&gt;
  &lt;span class="n"&gt;ts&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;math.max&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="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ts&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="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;math.min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;refill&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;tokens&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt;
  &lt;span class="n"&gt;allowed&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;end&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;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'HSET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'tokens'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ts'&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="c1"&gt;-- expire once a full refill would have happened anyway&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;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PEXPIRE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;math.ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;capacity&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;refill&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="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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;math.floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokens&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;Wiring it up with ioredis, which lets you register the script once and call it like a normal command:&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;Redis&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;ioredis&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;redis&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;Redis&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;REDIS_URL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defineCommand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;takeToken&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;numberOfKeys&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="na"&gt;lua&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LUA_SCRIPT&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;allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;capacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refillPerSec&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;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;}&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;remaining&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;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;takeToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`rl:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tenantId&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;capacity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refillPerSec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;allowed&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="nx"&gt;remaining&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;Two details matter. The timestamp comes from the application, not from &lt;code&gt;TIME&lt;/code&gt; inside the script — that keeps it deterministic and, more usefully, testable with a fake clock. And the &lt;code&gt;PEXPIRE&lt;/code&gt; is what keeps this from becoming an unbounded key space: idle tenants evict themselves once enough time has passed that their bucket would be full anyway.&lt;/p&gt;

&lt;p&gt;Token bucket, not fixed window, is the default I reach for because it allows a legitimate short burst while still holding the long-run average — which is usually what a customer expects from "100 per minute."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The atomic script isn't an optimization; a rate limiter with a read-modify-write race is a rate limiter that fails precisely under load.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Can you do this in Postgres if you don't want another dependency?
&lt;/h2&gt;

&lt;p&gt;Yes, and for a lot of small teams it's the right call. A fixed-window counter is a single atomic 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;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;rate_limit_counters&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;bucket_key&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;window_start&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="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;hits&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="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;bucket_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;rate_limit_counters&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&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;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'minute'&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;conflict&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rate_limit_counters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One round trip, returns the post-increment count, no race. Delete rows older than a couple of windows on a schedule and the table stays tiny.&lt;/p&gt;

&lt;p&gt;The honest drawbacks: every request now writes to your primary database. That's WAL traffic, dead tuples, and autovacuum work proportional to request rate, and it takes a connection from the same pool your real queries use — so a traffic spike contends with itself twice. Fixed windows also allow a boundary burst: a caller can spend a full window's budget in the last second of one window and again in the first second of the next, so a "100/min" limit tolerates 200 requests inside one two-second span. You can smooth that with a sliding window (two adjacent counters, weighted by how far you are into the current window), which is worth doing if the burst actually hurts you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Postgres is a perfectly good rate limit store right up until the limiter's write volume becomes a meaningful fraction of your database's write volume.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What belongs at the edge instead?
&lt;/h2&gt;

&lt;p&gt;The edge sees a request before it costs you anything, which is exactly why it's the right place for blunt limits and the wrong place for nuanced ones. It can't tell that this caller is on the Scale plan with a quota that resets on the 3rd — that's app knowledge. It can tell that one IP is sending 5,000 requests a minute to your login endpoint.&lt;/p&gt;

&lt;p&gt;If you want that layer managed, Cloudflare's rate limiting rules will drop volumetric abuse at the edge before it reaches your origin, at the cost of only being able to key on what a request looks like from outside your app. For serverless and edge runtimes where holding a long-lived Redis TCP connection is awkward, Upstash offers Redis over HTTP with a per-request pricing model, which sidesteps the connection-pooling problem those runtimes have. If you already terminate traffic through an API gateway, its built-in throttling usually covers the coarse layer — but check whether its counter is per-node or global, because managed gateways aren't automatically immune to the bug this post opens with.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use the edge to protect your infrastructure and the app to enforce your contract; trying to make one layer do both is where the design gets bad.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What should the response actually say?
&lt;/h2&gt;

&lt;p&gt;A 429 with an empty body teaches the caller nothing, and a client that doesn't know when to retry will just retry immediately.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Return &lt;code&gt;429 Too Many Requests&lt;/code&gt; with a &lt;code&gt;Retry-After&lt;/code&gt; header, in seconds. This one is a real standard and well-supported client-side.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;RateLimit-Limit&lt;/code&gt;, &lt;code&gt;RateLimit-Remaining&lt;/code&gt;, and &lt;code&gt;RateLimit-Reset&lt;/code&gt; if you want callers to self-pace. As of mid-2026 those fields are still an IETF draft rather than a finished RFC, and plenty of APIs use the older &lt;code&gt;X-RateLimit-*&lt;/code&gt; spelling — pick one, document it, don't change it.&lt;/li&gt;
&lt;li&gt;Decide fail-open vs fail-closed explicitly. If Redis is unreachable, does the request pass or get rejected? For a public API guarding cost, fail-closed; for an internal service where the limiter is a safety belt, fail-open with a loud alert. What you don't want is for that to be an accident of where your &lt;code&gt;try/catch&lt;/code&gt; happens to sit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Write the fail-open decision down in the code as a named constant, because you will only find out what you chose during an incident.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Should I rate limit by IP address or by API key?&lt;/strong&gt;&lt;br&gt;
By API key or account ID whenever the caller is authenticated — IPs are shared by corporate NATs and mobile carriers, so IP limits punish legitimate users in groups. Use IP limits only for unauthenticated endpoints like login and signup, where there's no better identity available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need Redis to rate limit an API?&lt;/strong&gt;&lt;br&gt;
No. A single Postgres table with an upsert-and-return counter is atomic and correct, and it's the right choice if your request volume is well below your database's write capacity. Move to Redis when the limiter's writes start showing up in your database's load profile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's the difference between 429 and 503 for rate limiting?&lt;/strong&gt;&lt;br&gt;
Return 429 when this specific caller exceeded their quota — it's about them, and &lt;code&gt;Retry-After&lt;/code&gt; tells them when to come back. Return 503 when your service as a whole is shedding load; 429 implies the caller can fix the problem by slowing down, 503 doesn't.&lt;/p&gt;

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

&lt;p&gt;If you're running more than one instance, the in-memory limiter you installed on day one is not enforcing the number you think it is — verify that first, before you tune anything. Small teams with an existing Postgres and moderate traffic should start with the upsert counter and skip the extra dependency entirely. Reach for Redis with an atomic Lua token bucket when per-request database writes stop being free, and add an edge rule on top when the traffic you're fighting is a flood rather than a customer over quota. Whichever you choose, write the 429 response and the store-is-down behavior deliberately, because those two things are what your callers and your on-call actually experience.&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/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;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;/ul&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>architecture</category>
      <category>performance</category>
    </item>
    <item>
      <title>Why Your Query Is Fast in psql but Slow From the App: Postgres Plan Caching Explained</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Fri, 21 Aug 2026 03:14:41 +0000</pubDate>
      <link>https://dev.to/libme/why-your-query-is-fast-in-psql-but-slow-from-the-app-postgres-plan-caching-explained-3omj</link>
      <guid>https://dev.to/libme/why-your-query-is-fast-in-psql-but-slow-from-the-app-postgres-plan-caching-explained-3omj</guid>
      <description>&lt;p&gt;If a query is instant when you paste it into &lt;code&gt;psql&lt;/code&gt; but slow when your application runs it, the plan is almost never the problem you think it is. The most common cause is that your driver sent it as a &lt;strong&gt;prepared statement&lt;/strong&gt;, and after a few executions Postgres switched from a plan tuned to your actual parameter values to a &lt;strong&gt;generic plan&lt;/strong&gt; built without them. The fix is usually a one-line setting, not a new index.&lt;/p&gt;

&lt;p&gt;I lost the better part of a day to this on a table with heavily skewed data. Same database, same user, same query text, 300x difference in latency depending on who was asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the symptom actually looks like
&lt;/h2&gt;

&lt;p&gt;The tell is that the two environments disagree while everything you can see is identical:&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;-- in psql: 2.8 ms&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;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'acme'&lt;/span&gt; &lt;span class="k"&gt;AND&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;'pending'&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;created_at&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;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application logs the same statement at 800–1200ms. There's an index on &lt;code&gt;(tenant_id, status, created_at DESC)&lt;/code&gt;. &lt;code&gt;EXPLAIN&lt;/code&gt; in psql shows a clean index scan. Nothing in the app is doing anything exotic — one query, 50 rows.&lt;/p&gt;

&lt;p&gt;What differs is &lt;em&gt;how&lt;/em&gt; the statement reaches the server. In psql you sent literal values, so the planner saw &lt;code&gt;tenant_id = 'acme'&lt;/code&gt; and could use the column statistics for that specific value. Your driver sent &lt;code&gt;WHERE tenant_id = $1&lt;/code&gt; with the value bound separately. Postgres plans that once and may reuse the plan.&lt;/p&gt;

&lt;p&gt;Takeaway: when psql and the app disagree on speed for the same SQL, stop looking at indexes and start looking at parameter binding.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does Postgres decide to reuse a plan?
&lt;/h2&gt;

&lt;p&gt;For a prepared statement, Postgres builds a &lt;em&gt;custom&lt;/em&gt; plan (re-planned with the actual parameters) for the first executions, tracks their estimated cost, and compares that average against the cost of a &lt;em&gt;generic&lt;/em&gt; plan built with no knowledge of the values. From the sixth execution onward, if the generic plan doesn't look more expensive on average, it locks in and stops re-planning. That threshold is a fixed number in the source, not a knob you tune.&lt;/p&gt;

&lt;p&gt;This is a good trade when data is uniform. It is destructive when it isn't. If 98% of your rows are &lt;code&gt;tenant_id = 'bigcorp'&lt;/code&gt;, the generic plan is built around the &lt;em&gt;average&lt;/em&gt; selectivity across all tenants, and the planner concludes a sequential scan or a bitmap heap scan is reasonable. For &lt;code&gt;acme&lt;/code&gt; — 400 rows out of 40 million — that plan is catastrophic, and it will be reused for the entire life of that connection.&lt;/p&gt;

&lt;p&gt;Two more things that only bite the parameterized version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Partial indexes stop matching.&lt;/strong&gt; An index defined &lt;code&gt;WHERE status = 'pending'&lt;/code&gt; can be matched against a literal &lt;code&gt;status = 'pending'&lt;/code&gt; but not against &lt;code&gt;status = $2&lt;/code&gt;, because at generic-plan time the planner doesn't know &lt;code&gt;$2&lt;/code&gt; is &lt;code&gt;'pending'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LIKE prefix optimization disappears.&lt;/strong&gt; &lt;code&gt;LIKE 'abc%'&lt;/code&gt; can be rewritten into a range scan; &lt;code&gt;LIKE $1&lt;/code&gt; cannot, without the value.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Takeaway: a generic plan is planned for your &lt;em&gt;average&lt;/em&gt; row, so any column with skewed values is a landmine.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I prove this is what's happening?
&lt;/h2&gt;

&lt;p&gt;Don't guess from application timings. Reproduce it in a single psql session — the behavior is per-connection, and you can drive it by hand:&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;PREPARE&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;text&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;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;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_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="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&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;created_at&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;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'acme'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;-- run this 6+ times&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it repeatedly and watch the plan. If execution 1 shows an Index Scan and execution 6 flips to a Seq Scan or a Bitmap Heap Scan, you have your answer — and you've reproduced it without touching the app.&lt;/p&gt;

&lt;p&gt;On Postgres 16 and later you can skip the ritual and ask directly:&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;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GENERIC_PLAN&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;events&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tenant_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="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&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;created_at&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;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To catch it in production instead of on your laptop, enable &lt;code&gt;auto_explain&lt;/code&gt; and have the slow plans logged as they happen:&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;LOAD&lt;/span&gt; &lt;span class="s1"&gt;'auto_explain'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;auto_explain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log_min_duration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'200ms'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;auto_explain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log_analyze&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loading it per-session like this is the safe way to test; making it permanent means adding it to &lt;code&gt;shared_preload_libraries&lt;/code&gt; and a restart, and &lt;code&gt;log_analyze&lt;/code&gt; adds real per-query overhead, so keep the duration threshold high on a busy server. If you want to know which statements to point it at first, &lt;code&gt;pg_stat_statements&lt;/code&gt; is the extension that tells you where the time actually goes — it aggregates by normalized query text, so the parameterized version shows up as one row with its own mean and max.&lt;/p&gt;

&lt;p&gt;Takeaway: &lt;code&gt;PREPARE&lt;/code&gt; plus six &lt;code&gt;EXECUTE&lt;/code&gt;s in one psql session reproduces the bug in under a minute, which is faster than any amount of reading application logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do I actually change?
&lt;/h2&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;Fix&lt;/th&gt;
&lt;th&gt;Cost of the fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Skewed column, plan flips to generic&lt;/td&gt;
&lt;td&gt;&lt;code&gt;plan_cache_mode = force_custom_plan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Re-plans every execution (planning is ~µs–low ms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only one or two queries affected&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;plan_cache_mode&lt;/code&gt; on that session/transaction only&lt;/td&gt;
&lt;td&gt;Needs a code path to scope it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver prepares everything by default&lt;/td&gt;
&lt;td&gt;Lower or disable the driver's prepare threshold&lt;/td&gt;
&lt;td&gt;Loses prepared-statement parse savings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PgBouncer in transaction mode&lt;/td&gt;
&lt;td&gt;Configure prepared-statement support, or stop using named statements&lt;/td&gt;
&lt;td&gt;Version-dependent; see below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plan is fine, stats are stale&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ANALYZE&lt;/code&gt;, raise &lt;code&gt;default_statistics_target&lt;/code&gt; on that column&lt;/td&gt;
&lt;td&gt;More planning time, better estimates&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The blunt instrument, available since Postgres 12:&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;-- per session, or per role/database via ALTER ROLE ... SET&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;plan_cache_mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;force_custom_plan&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I default to scoping this narrowly. Setting it database-wide fixes the skewed queries and quietly taxes every other prepared statement with re-planning forever. &lt;code&gt;ALTER ROLE app_worker SET plan_cache_mode = 'force_custom_plan'&lt;/code&gt; on the specific worker role that runs the tenant-scoped queries is usually the right blast radius.&lt;/p&gt;

&lt;p&gt;Where the driver sits matters as much as the server setting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;node-postgres (&lt;code&gt;pg&lt;/code&gt;)&lt;/strong&gt; only creates a named prepared statement when you pass a &lt;code&gt;name&lt;/code&gt; in the query config. Plain &lt;code&gt;client.query(text, values)&lt;/code&gt; is unnamed — parsed each time, planned with the values, immune to this problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The PostgreSQL JDBC driver&lt;/strong&gt; switches to a server-side prepared statement after a handful of executions of the same statement, controlled by &lt;code&gt;prepareThreshold&lt;/code&gt;. Setting it to &lt;code&gt;0&lt;/code&gt; disables server-side preparation entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ORMs on top of connection poolers&lt;/strong&gt; are where this gets ugly. If you run PgBouncer in transaction pooling mode, named prepared statements historically broke outright because the server connection underneath you changes between transactions. PgBouncer added support for protocol-level prepared statements in 1.21 (released late 2023), gated behind &lt;code&gt;max_prepared_statements&lt;/code&gt;, which defaults to 0 — meaning off unless you turned it on. If you want a pooler where this isn't a running concern, PgBouncer with &lt;code&gt;max_prepared_statements&lt;/code&gt; configured explicitly is the setup that keeps prepared statements and transaction pooling working together instead of forcing you to choose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Takeaway: fix it at the narrowest scope that works — one role or one query beats a database-wide setting you'll forget you set.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if it isn't the plan cache?
&lt;/h2&gt;

&lt;p&gt;Before you go changing planner settings, rule out the other reasons psql and your app disagree. In rough order of how often I've actually hit them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;You're not measuring the same thing.&lt;/strong&gt; App timing usually includes connection acquisition, TLS, row serialization, and ORM hydration. A query that's 3ms at the server and 900ms in your log may be spending 890ms turning rows into objects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different &lt;code&gt;search_path&lt;/code&gt; or role.&lt;/strong&gt; The app connects as a different role and hits a different schema — often an unindexed copy in a test schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Session GUCs.&lt;/strong&gt; &lt;code&gt;work_mem&lt;/code&gt; set per-role changes whether a sort spills to disk. &lt;code&gt;statement_timeout&lt;/code&gt; masks the real duration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connection acquisition, not query time.&lt;/strong&gt; If the pool is exhausted, every query looks slow. &lt;code&gt;pg_stat_activity&lt;/code&gt; will show sessions waiting, not running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actual row counts differ.&lt;/strong&gt; Your app pages through 50,000 rows; you tested &lt;code&gt;LIMIT 50&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The clean way to separate these: log the server-side duration with &lt;code&gt;log_min_duration_statement&lt;/code&gt; and compare it against the duration your application recorded. If the server says 3ms and your app says 900ms, the database is innocent.&lt;/p&gt;

&lt;p&gt;Takeaway: confirm the server itself is slow before you tune the server.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why is my query fast in pgAdmin but slow in my application?&lt;/strong&gt;&lt;br&gt;
Almost always because your application sends the query as a parameterized prepared statement and the GUI tool sends literal values. After roughly five executions Postgres may switch that prepared statement to a generic plan built without your parameter values, which performs badly on columns with skewed data. Reproduce it with &lt;code&gt;PREPARE&lt;/code&gt; and repeated &lt;code&gt;EXECUTE&lt;/code&gt; in psql.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I stop Postgres from using a generic plan?&lt;/strong&gt;&lt;br&gt;
Set &lt;code&gt;plan_cache_mode = force_custom_plan&lt;/code&gt;, available since Postgres 12. Scope it to the session, transaction, or role that runs the affected queries rather than setting it database-wide, since it forces re-planning on every execution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does PgBouncer break prepared statements?&lt;/strong&gt;&lt;br&gt;
In transaction pooling mode it did, because the underlying server connection changes between transactions. PgBouncer 1.21 added support for protocol-level prepared statements via the &lt;code&gt;max_prepared_statements&lt;/code&gt; setting, which is disabled by default — you have to set it to a non-zero value explicitly.&lt;/p&gt;

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

&lt;p&gt;If psql is fast and your app is slow on identical SQL, check parameter binding before you add an index. Reproduce it with &lt;code&gt;PREPARE&lt;/code&gt; plus six &lt;code&gt;EXECUTE&lt;/code&gt;s, and if the plan flips, set &lt;code&gt;plan_cache_mode = force_custom_plan&lt;/code&gt; on the narrowest scope that covers the offending queries — a role, ideally, not the whole database. If the plan doesn't flip, the problem is on your side of the wire: measure server-side duration with &lt;code&gt;log_min_duration_statement&lt;/code&gt; and compare, because ORM hydration and pool waits both look exactly like a slow query from the application's point of view.&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/add-full-text-search-to-your-app-before-reaching-for-elasticsearch-lmc"&gt;Add Full-Text Search to Your App Before Reaching for Elasticsearch&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/when-should-you-move-off-managed-hosting-to-kubernetes-the-thresholds-that-actually-matter-5bbl"&gt;When Should You Move Off Managed Hosting to Kubernetes? The Thresholds That Actually Matter&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
      <category>backend</category>
    </item>
    <item>
      <title>Your Secrets Manager Ends at process.env: Where Secrets Actually Leak at Runtime</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Thu, 20 Aug 2026 12:57:39 +0000</pubDate>
      <link>https://dev.to/libme/your-secrets-manager-ends-at-processenv-where-secrets-actually-leak-at-runtime-3b8b</link>
      <guid>https://dev.to/libme/your-secrets-manager-ends-at-processenv-where-secrets-actually-leak-at-runtime-3b8b</guid>
      <description>&lt;p&gt;Whichever secrets store you pick — a cloud provider's secret manager, Doppler, 1Password, Vault, or encrypted files in git — almost all of them hand your process the same thing at boot: a flat blob of environment variables. That means the vendor decision governs storage, distribution, and rotation, but it barely touches the path most small-team secret leaks actually take, which is a secret getting &lt;em&gt;printed&lt;/em&gt;: into a log line, an error report, a subprocess, or a CI debug step. A commenter on an earlier post made exactly this point, and it's the more useful half of the problem.&lt;/p&gt;

&lt;p&gt;This post is about what you do after the vendor choice. It's Node-flavored, but the leak paths are language-agnostic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the store barely change the leak path?
&lt;/h2&gt;

&lt;p&gt;Every injector converges. &lt;code&gt;doppler run -- node server.js&lt;/code&gt;, &lt;code&gt;op run -- node server.js&lt;/code&gt;, Vault Agent templating a file, Kubernetes &lt;code&gt;envFrom.secretRef&lt;/code&gt;, or a plain &lt;code&gt;.env&lt;/code&gt; — by the time your first line of code executes, the secret is a string in &lt;code&gt;process.env&lt;/code&gt;, readable by anything in the process and by anything that can serialize an object containing it.&lt;/p&gt;

&lt;p&gt;So the threat model shifts. Storage-layer questions ("who can read the secret in the vault?") are answered by the vendor. Runtime questions are yours alone:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Leak path&lt;/th&gt;
&lt;th&gt;Who ends up seeing it&lt;/th&gt;
&lt;th&gt;Does the vendor help?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;console.log(config)&lt;/code&gt; in a debug session&lt;/td&gt;
&lt;td&gt;Anyone with log access, forever&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error reporter attaching request/config context&lt;/td&gt;
&lt;td&gt;Your SaaS error tracker's operators&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;child_process.spawn&lt;/code&gt; inheriting the full env&lt;/td&gt;
&lt;td&gt;Any script you shell out to, and its logs&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;/proc/&amp;lt;pid&amp;gt;/environ&lt;/code&gt; on the box&lt;/td&gt;
&lt;td&gt;Any process running as the same user&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;kubectl describe pod&lt;/code&gt; with literal env values&lt;/td&gt;
&lt;td&gt;Anyone with read access to the namespace&lt;/td&gt;
&lt;td&gt;Partly — &lt;code&gt;secretKeyRef&lt;/code&gt; shows the reference, not the value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI job echoing a derived value&lt;/td&gt;
&lt;td&gt;Anyone who can read build logs&lt;/td&gt;
&lt;td&gt;Partly — masking only catches exact matches&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of those deserve a note. On Linux, a process's environment is readable at &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/environ&lt;/code&gt; by processes running as the same user, so "it's only in memory" is weaker than it sounds on a shared host. And in Kubernetes, env vars sourced with &lt;code&gt;secretKeyRef&lt;/code&gt; show up in &lt;code&gt;kubectl describe pod&lt;/code&gt; as a reference rather than a value — but env vars set as literals in a manifest are printed in full.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The vendor decides who can fetch the secret; your code decides how many places it can be printed.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you stop a secret from ever printing?
&lt;/h2&gt;

&lt;p&gt;Make the secret a type that cannot serialize, instead of a string you promise never to log. In Node, three hooks cover essentially every accidental print: &lt;code&gt;toString&lt;/code&gt;, &lt;code&gt;toJSON&lt;/code&gt;, and the custom inspect symbol.&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;// secret.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REDACTED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[redacted]&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;class&lt;/span&gt; &lt;span class="nc"&gt;Secret&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&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="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;expose&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="err"&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="nf"&gt;toString&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;REDACTED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;toJSON&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;REDACTED&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="nb"&gt;Symbol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nodejs.util.inspect.custom&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="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`Secret(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;console.log(secret)&lt;/code&gt; prints &lt;code&gt;Secret(STRIPE_SECRET_KEY)&lt;/code&gt;, &lt;code&gt;JSON.stringify({ secret })&lt;/code&gt; produces &lt;code&gt;{"secret":"[redacted]"}&lt;/code&gt;, and a template literal like &lt;code&gt;`key=${secret}`&lt;/code&gt; yields &lt;code&gt;key=[redacted]&lt;/code&gt;. The only way to get the real value out is &lt;code&gt;.expose()&lt;/code&gt; — a single greppable token that shows up in code review.&lt;/p&gt;

&lt;p&gt;Wire it into one env module that parses at boot and fails loudly, then removes the raw values from the ambient environment:&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;// env.js — the only file allowed to touch process.env&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;z&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;zod&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="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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./secret.js&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;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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="na"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sk_&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;SESSION_SECRET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;LOG_LEVEL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;info&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;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&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="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;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&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;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid environment:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;fieldErrors&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SENSITIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DATABASE_URL&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="s2"&gt;STRIPE_SECRET_KEY&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="s2"&gt;SESSION_SECRET&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;const&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;SENSITIVE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;k&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;k&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;Secret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;k&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;// Shrink the blast radius: after this point the raw strings are not&lt;/span&gt;
&lt;span class="c1"&gt;// in the ambient environment, so a subprocess or a dump can't pick them up.&lt;/span&gt;
&lt;span class="k"&gt;for &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;key&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;SENSITIVE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;delete&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;key&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call sites become explicit:&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;Pool&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;pg&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="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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./env.js&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;const&lt;/span&gt; &lt;span class="nx"&gt;pool&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;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&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;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expose&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;Two honest caveats. First, &lt;code&gt;delete process.env.X&lt;/code&gt; only helps if it runs before anything else reads that variable, so this module must be imported first — and you must &lt;strong&gt;not&lt;/strong&gt; delete variables that libraries read from the environment themselves (&lt;code&gt;AWS_*&lt;/code&gt; for the AWS SDK, &lt;code&gt;PGPASSWORD&lt;/code&gt; for libpq, &lt;code&gt;OTEL_*&lt;/code&gt; for OpenTelemetry). Delete only the keys your own code owns. Second, the &lt;code&gt;Secret&lt;/code&gt; wrapper stops accidental printing, not a determined &lt;code&gt;.expose()&lt;/code&gt; in the wrong place; it converts a whole class of invisible mistakes into a visible one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A secret that redacts itself on serialization turns "we should be careful with logging" into a property of the type system rather than a team norm.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What about the loggers and error reporters?
&lt;/h2&gt;

&lt;p&gt;Structured loggers can redact by path. With Pino:&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;pino&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;pino&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;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="na"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;paths&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="s2"&gt;req.headers.authorization&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="s2"&gt;req.headers.cookie&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="s2"&gt;*.password&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="s2"&gt;*.token&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="s2"&gt;*.secret&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;censor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[redacted]&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;The limitation matters more than the feature: path-based redaction only inspects the properties of the logged object. If someone writes &lt;code&gt;log.info(`connecting to ${url}`)&lt;/code&gt; with the secret already interpolated into the string, redaction sees a plain message and does nothing. That's precisely why the &lt;code&gt;Secret&lt;/code&gt; class above overrides &lt;code&gt;toString&lt;/code&gt; — the two mechanisms cover each other's gaps.&lt;/p&gt;

&lt;p&gt;For error tracking, the risk isn't usually the framework grabbing your environment; it's you attaching a config object to the scope. Sentry's Node SDK doesn't ship &lt;code&gt;process.env&lt;/code&gt; on its own, but &lt;code&gt;setContext("config", config)&lt;/code&gt; will happily upload whatever you hand it, and a &lt;code&gt;beforeSend&lt;/code&gt; hook is the right place to enforce that:&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;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;dsn&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;SENTRY_DSN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;beforeSend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contexts&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;config&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&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="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&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="nx"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&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="nx"&gt;cookie&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;event&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;strong&gt;Redaction that runs at the logging boundary is necessary but not sufficient — anything that stringifies a secret before it reaches the boundary sails straight through.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do subprocesses and CI leak secrets?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;child_process.spawn&lt;/code&gt; and &lt;code&gt;exec&lt;/code&gt; pass the parent's entire environment to the child by default. Every shell script, every image-processing binary, every migration tool you shell out to receives your full secret set, and if that child prints its environment on error, so does your log. Pass an allowlist instead:&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;spawn&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:child_process&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./scripts/import.sh&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;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&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;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;PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;NODE_ENV&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;NODE_ENV&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;IMPORT_BUCKET&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;IMPORT_BUCKET&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;CI has the same shape with an extra trap. GitHub Actions masks registered secret values in log output, but the mask is a literal string match: base64-encode a secret, slice it, or embed it in a JSON payload you print, and the transformed value is not masked. Treat CI masking as a safety net for typos, not as a control.&lt;/p&gt;

&lt;p&gt;Finally, make the invariant testable so it doesn't decay:&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;inspect&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:util&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="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="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../env.js&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="s2"&gt;env never serializes secret values&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;dump&lt;/span&gt; &lt;span class="o"&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;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;inspect&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;});&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;dump&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/sk_&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&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;dump&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/postgres:&lt;/span&gt;&lt;span class="se"&gt;\/\/[^&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&gt;*:&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;\s]&lt;/span&gt;&lt;span class="sr"&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;Pair it with an ESLint &lt;code&gt;no-restricted-properties&lt;/code&gt; rule banning &lt;code&gt;process.env&lt;/code&gt; outside &lt;code&gt;env.js&lt;/code&gt;, and the runtime hygiene survives new contributors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your secret handling isn't covered by a test that fails, it's a convention, and conventions regress at the third hire.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Does any of this change which store you should pick?
&lt;/h2&gt;

&lt;p&gt;Slightly, at the margins. If you want an injector that also scrubs the child process's output, 1Password's CLI is the one that masks known secret values in the stdout and stderr of the command it runs, which catches sloppy prints you didn't anticipate — though it only knows the values it injected, and it puts a vendor CLI in the boot path of every local command. Doppler's &lt;code&gt;doppler run&lt;/code&gt; is the most frictionless injector for a mixed team and handles rotation cleanly, but it's still a plain env injection, so everything above still applies. Vault can template secrets to a tmpfs file that your app reads and re-reads, which keeps them out of the environment entirely and supports rotation without a restart — at the cost of running and operating Vault, which is a real job. As of mid-2026, no injector removes the need for the &lt;code&gt;Secret&lt;/code&gt; type and the logging rules; they only reduce how much you're gambling on discipline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick the store for rotation and access control, then assume it delivers a plain env blob and design the runtime as if it did.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Is it safe to store secrets in environment variables?&lt;/strong&gt;&lt;br&gt;
It's acceptable for most small teams, but it's not free. Environment variables are visible to child processes, readable at &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/environ&lt;/code&gt; by same-user processes on Linux, and easy to serialize by accident. Load them once at startup, wrap them in a type that redacts on serialization, and delete the raw values from &lt;code&gt;process.env&lt;/code&gt; afterward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I stop secrets from appearing in logs in Node.js?&lt;/strong&gt;&lt;br&gt;
Use two layers. Wrap secret values in a class that overrides &lt;code&gt;toString&lt;/code&gt;, &lt;code&gt;toJSON&lt;/code&gt;, and &lt;code&gt;Symbol.for("nodejs.util.inspect.custom")&lt;/code&gt; so interpolation and inspection print &lt;code&gt;[redacted]&lt;/code&gt;, and configure your structured logger's redaction paths for request headers and common field names. Path-based redaction alone misses secrets already interpolated into a message string.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do child processes inherit environment variables in Node.js?&lt;/strong&gt;&lt;br&gt;
Yes. &lt;code&gt;spawn&lt;/code&gt;, &lt;code&gt;exec&lt;/code&gt;, and &lt;code&gt;fork&lt;/code&gt; pass the parent's full &lt;code&gt;process.env&lt;/code&gt; to the child unless you set the &lt;code&gt;env&lt;/code&gt; option explicitly. Pass an allowlist containing only the variables the child actually needs.&lt;/p&gt;

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

&lt;p&gt;Choosing between a cloud secret manager, Doppler, 1Password, Vault, and SOPS is a real decision, and it's the right one to make for rotation speed and access control. But it ends at your process boundary. Spend the afternoon after that decision on the runtime side: one env module that parses and fails fast, a &lt;code&gt;Secret&lt;/code&gt; type that can't serialize, logger redaction, an allowlist for subprocess environments, and one test that fails if a raw value ever shows up in a dump. That's the part that actually determines whether your next incident is a stack trace or a secret in a log line someone screenshots into Slack.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/where-should-a-small-team-store-its-secrets-comparing-env-files-sops-doppler-1password-and-e13"&gt;Where Should a Small Team Store Its Secrets? Comparing .env Files, SOPS, Doppler, 1Password, and Vault&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;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;/ul&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>node</category>
      <category>backend</category>
    </item>
    <item>
      <title>When Should You Move Off Managed Hosting to Kubernetes? The Thresholds That Actually Matter</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Thu, 20 Aug 2026 08:42:41 +0000</pubDate>
      <link>https://dev.to/libme/when-should-you-move-off-managed-hosting-to-kubernetes-the-thresholds-that-actually-matter-5bbl</link>
      <guid>https://dev.to/libme/when-should-you-move-off-managed-hosting-to-kubernetes-the-thresholds-that-actually-matter-5bbl</guid>
      <description>&lt;p&gt;If you are trying to decide whether it is finally time to run a cluster, the honest answer is that request volume is almost never the trigger. The triggers are structural: per-tenant isolation you cannot express in your database, workloads you do not trust, a compliance boundary that requires network-level segmentation with an audit trail, or a pile of homegrown scripts that have quietly become a bad orchestrator. Before any of those, you should be able to prove you have run out of headroom on the managed platform you already pay for.&lt;/p&gt;

&lt;p&gt;A reader on an earlier post about boring stacks made the sharpest version of this point: the real failure mode that pushes teams to Kubernetes is not high traffic, it is complex multi-tenant stateful workloads and compliance boundaries that a managed platform cannot express. They then asked the question this post exists to answer — what specific metrics and team thresholds should sit in the decision table. Here is the one I use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is "we're getting too much traffic" almost never the real trigger?
&lt;/h2&gt;

&lt;p&gt;Because managed platforms scale vertically and horizontally long past where most teams assume they stop. A single large instance running a well-tuned application server handles a volume of traffic that surprises people who have only ever read about scale, and every managed runtime worth using will run several identical copies of your container behind a load balancer without you learning a new config language.&lt;/p&gt;

&lt;p&gt;Before you accept "we need to scale" as an argument, measure your actual headroom. The database is usually the real ceiling, and it is the one people misread most often — an app that falls over at 300 concurrent requests is frequently exhausting Postgres connections, not CPU:&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;setting&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_settings&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'max_connections'&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;max_connections&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;in_use&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;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&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;setting&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_settings&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'max_connections'&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pct_used&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="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that comes back at 95% while your instance CPU sits at 20%, Kubernetes solves nothing for you. A connection pooler and a smaller per-process pool size solve it in an afternoon. The same applies to the app tier: if your peak CPU utilization never crosses 40% on the instance size you are paying for, you do not have a scaling problem, you have a latency or concurrency problem hiding behind one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you cannot point at a specific resource you have saturated after tuning, you do not have a scaling argument — you have a discomfort.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What are the structural signals that actually justify a cluster?
&lt;/h2&gt;

&lt;p&gt;These are the four I treat as genuine. Each one has a test you can apply today, and each one has a boring-stack workaround you should exhaust first.&lt;/p&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;How to test it&lt;/th&gt;
&lt;th&gt;Try this first&lt;/th&gt;
&lt;th&gt;If that fails&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Per-tenant stateful isolation&lt;/td&gt;
&lt;td&gt;Can one tenant's data or load reach another's, in a way a customer contract forbids?&lt;/td&gt;
&lt;td&gt;Row-level security, or a schema/database per tenant on managed Postgres&lt;/td&gt;
&lt;td&gt;Per-tenant namespaces with resource quotas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Untrusted or arbitrary workloads&lt;/td&gt;
&lt;td&gt;Are you executing code you did not write — customer plugins, build jobs, notebooks?&lt;/td&gt;
&lt;td&gt;Managed sandbox/build services, hard per-job timeouts&lt;/td&gt;
&lt;td&gt;Real pod-level isolation and admission control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compliance boundary&lt;/td&gt;
&lt;td&gt;Does an auditor need to see enforced network segmentation and deploy provenance?&lt;/td&gt;
&lt;td&gt;Separate managed environments per boundary, VPC peering rules&lt;/td&gt;
&lt;td&gt;Network policies, RBAC, signed image admission&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy coordination&lt;/td&gt;
&lt;td&gt;Count the lines of custom bash that restart, health-check, and roll out services&lt;/td&gt;
&lt;td&gt;A managed runtime with health checks and rolling deploys&lt;/td&gt;
&lt;td&gt;Declarative orchestration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The multi-tenancy row is where most teams misdiagnose themselves, so it deserves the concrete version. "Multi-tenant stateful workload" usually means shared rows in shared tables, and that is a database problem with a database answer:&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;invoices&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&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;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then set the tenant once per transaction, from your connection checkout or request middleware:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'3f1c2a9e-0b47-4f8a-9d21-5c6e8f0a1b23'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&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;'open'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&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;FORCE ROW LEVEL SECURITY&lt;/code&gt; matters because policies are skipped for the table owner by default, which is exactly the role most applications connect as — that omission is the single most common way an RLS setup looks correct and enforces nothing. &lt;code&gt;SET LOCAL&lt;/code&gt; scopes the setting to the transaction so a pooled connection cannot leak one tenant's context into the next request.&lt;/p&gt;

&lt;p&gt;If that satisfies your isolation requirement, you did not need an orchestrator, you needed twelve lines of SQL. If your requirement is instead "tenant A's batch job must never consume tenant B's CPU," you have a genuine scheduling problem and the case for Kubernetes gets real.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Isolation requirements that live in your data model are database work; isolation requirements that live in the kernel are orchestrator work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What team size can actually carry a cluster?
&lt;/h2&gt;

&lt;p&gt;The number I use is not a headcount, it is an ownership commitment: someone whose job description includes the cluster, with roughly half a day a week of unglamorous maintenance budgeted, and at least one other person who can do a node upgrade when that person is on vacation. On a team of five engineers that is 10% of your engineering capacity gone, permanently, to something your customers never see.&lt;/p&gt;

&lt;p&gt;The recurring work is real and it does not go away with a managed control plane: node group upgrades on the provider's deprecation schedule, ingress controller and cert-manager version bumps, CNI plugin compatibility, RBAC drift, and the periodic afternoon spent discovering that a pod is pending because of a resource request nobody remembers writing. Managed offerings like EKS and GKE remove the control plane from your worry list and genuinely lower the floor, but the worker nodes, networking, and deploy pipeline are still yours.&lt;/p&gt;

&lt;p&gt;There is also a middle tier that most decision tables skip entirely. If your only real need is "run these containers, restart them when they die, roll them out without downtime," AWS Fargate gives you container scheduling with no nodes to patch, at the cost of slower cold starts and a weaker local development story. If your workloads are HTTP services that can tolerate scale-to-zero, Google Cloud Run handles request-driven autoscaling and TLS termination without exposing you to any cluster concepts at all, though it constrains you to its request lifecycle. If you want orchestration primitives without the Kubernetes ecosystem surface area, HashiCorp Nomad schedules containers and plain binaries with a config format a new engineer can read in an afternoon, with the tradeoff of a much smaller community and fewer off-the-shelf integrations.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Adopt Kubernetes when you need what only Kubernetes gives you, not when you need what any scheduler gives you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you make the switch without a big-bang migration?
&lt;/h2&gt;

&lt;p&gt;Move one stateless service first, and pick the least important one you have — an internal admin tool, a metrics exporter, a webhook receiver. It should be something whose outage costs you an apology, not revenue. That first workload is how you discover the parts nobody writes tickets for: image pull secrets, DNS resolution inside the cluster, log shipping, and how you actually get a shell when something breaks.&lt;/p&gt;

&lt;p&gt;Keep state outside the cluster for as long as you can. Managed Postgres, managed object storage, and managed queues stay exactly where they are; running your own database inside Kubernetes is a separate project with its own operator, backup, and failover story, and taking both on at once is how migrations stall for a quarter.&lt;/p&gt;

&lt;p&gt;Set an explicit rollback condition before you start — something like "if the admin tool is not stable on the cluster within three weeks, it goes back." Migrations without a stated failure condition tend to continue on sunk cost alone.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Your first cluster workload should be chosen for how little it matters, not how well it demonstrates the platform.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;&lt;strong&gt;How many services do you need before Kubernetes is worth it?&lt;/strong&gt;&lt;br&gt;
There is no clean count, but the useful proxy is deploy coordination, not service count. If you are running fewer than roughly five services and your deploys are independent, a managed runtime handles it; once services must be rolled out in a specific order, share service discovery, and are held together by custom scripts you are afraid to edit, the orchestrator is doing work you are currently doing by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Kubernetes required for SOC 2 or HIPAA compliance?&lt;/strong&gt;&lt;br&gt;
No. Neither framework names any orchestrator. What auditors ask for is enforced access control, network segmentation between environments, encryption, and evidence of change management — all of which managed platforms can satisfy with separate environments and provider-level controls. Kubernetes becomes relevant when your segmentation requirements are finer-grained than the boundaries your platform lets you draw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you run a multi-tenant SaaS without Kubernetes?&lt;/strong&gt;&lt;br&gt;
Yes, and most do. Tenant isolation at the data layer with row-level security or a database per tenant covers the majority of contractual requirements. You need kernel-level isolation only when tenants can trigger workloads that compete for CPU and memory, or when you execute code the tenant supplied.&lt;/p&gt;

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

&lt;p&gt;If you are on a managed platform and shopping for a reason to leave it, measure your headroom first — connection saturation, CPU utilization at peak, and how much of your latency is your own code. Adopt Kubernetes when you hit a structural wall: untrusted workloads, isolation that must be enforced below your application, or an audit boundary your platform cannot draw. If you need scheduling but not the full ecosystem, Fargate, Cloud Run, and Nomad are the honest middle, and each costs you flexibility in exchange for the operational load it removes. And if nobody on your team can name the person who owns node upgrades six months from now, the answer for today is still no.&lt;/p&gt;

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

&lt;ul&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;li&gt;&lt;a href="https://dev.to/libme/netlify-pros-and-cons-when-its-the-right-host-and-when-youll-outgrow-it-2ka1"&gt;Netlify Pros and Cons: When It's the Right Host, and When You'll Outgrow It&lt;/a&gt;&lt;/li&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;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>architecture</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Where Should a Small Team Store Its Secrets? Comparing .env Files, SOPS, Doppler, 1Password, and Vault</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Wed, 19 Aug 2026 21:42:06 +0000</pubDate>
      <link>https://dev.to/libme/where-should-a-small-team-store-its-secrets-comparing-env-files-sops-doppler-1password-and-e13</link>
      <guid>https://dev.to/libme/where-should-a-small-team-store-its-secrets-comparing-env-files-sops-doppler-1password-and-e13</guid>
      <description>&lt;p&gt;If your team is under about ten people and already runs on one cloud provider, the cheapest correct answer is your provider's own secret store plus OIDC in CI, and no long-lived keys anywhere. If you're spread across several providers or hand secrets to non-infra teammates, a hosted secrets manager with a CLI injector — Doppler or 1Password — pays for itself the first time a rotation doesn't take an afternoon. Encrypted files in git (SOPS) are the right answer for a narrow case: config that must version alongside code, reviewed in PRs.&lt;/p&gt;

&lt;p&gt;What almost never works past the second engineer is passing &lt;code&gt;.env&lt;/code&gt; files around in Slack. Here's why, concretely, and what to replace it with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode that ends the .env era
&lt;/h2&gt;

&lt;p&gt;The bug that finally forced this decision on me wasn't a breach. It was a deploy that came up healthy and served traffic against the wrong database for twenty minutes.&lt;/p&gt;

&lt;p&gt;The shape is always the same:&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;// db.js — the line that costs you an incident&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postgres://localhost:5432/app_dev&lt;/span&gt;&lt;span class="dl"&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 secret gets renamed in one place and not another, or a CI job runs before the env is populated, and &lt;code&gt;process.env.DATABASE_URL&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;. The &lt;code&gt;||&lt;/code&gt; fallback is silent by design. Nothing throws, health checks pass, and the failure only surfaces when someone notices writes going nowhere.&lt;/p&gt;

&lt;p&gt;Two fixes, and you want both. First, fail fast at boot so an absent secret is a crash, not a fallback:&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;// env.js — parse once, at startup, before anything connects&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;z&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;zod&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;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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="na"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sk_&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;SESSION_SECRET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&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="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&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="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;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&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;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid environment:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;fieldErrors&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Import &lt;code&gt;env&lt;/code&gt; everywhere instead of touching &lt;code&gt;process.env&lt;/code&gt; directly, and add a lint rule banning &lt;code&gt;process.env&lt;/code&gt; outside that one file. Now a missing secret is a loud, immediate, unambiguous crash.&lt;/p&gt;

&lt;p&gt;Second, remove the drift itself by having exactly one place a secret lives. That's the actual decision below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail-fast env parsing is the highest-leverage twenty lines in this entire post — do it before you pick a vendor, because it turns every secrets bug from a silent wrong answer into a stack trace.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the real options, and what does each cost you?
&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;Best when&lt;/th&gt;
&lt;th&gt;Real drawback&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.env&lt;/code&gt; files, shared manually&lt;/td&gt;
&lt;td&gt;Solo, one machine, throwaway projects&lt;/td&gt;
&lt;td&gt;No rotation story, no audit trail, leaks via Slack/backups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SOPS + age/KMS, committed to git&lt;/td&gt;
&lt;td&gt;Config that must version with code and be PR-reviewed&lt;/td&gt;
&lt;td&gt;Rotation = commit + redeploy; key management is on you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud-native (AWS Secrets Manager, Parameter Store, GCP Secret Manager, Azure Key Vault)&lt;/td&gt;
&lt;td&gt;Already all-in on one cloud&lt;/td&gt;
&lt;td&gt;Clumsy for local dev; per-secret and per-API-call billing adds up at high call volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Doppler&lt;/td&gt;
&lt;td&gt;Multi-provider deploys, want a fast CLI + integrations&lt;/td&gt;
&lt;td&gt;Another vendor in your boot path; SaaS-first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1Password Secrets Automation&lt;/td&gt;
&lt;td&gt;Team already lives in 1Password; humans and machines need the same vault&lt;/td&gt;
&lt;td&gt;Service-account model takes a beat to grasp; usage-metered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vault / OpenBao (self-hosted)&lt;/td&gt;
&lt;td&gt;Dynamic short-lived DB creds, strict compliance&lt;/td&gt;
&lt;td&gt;Genuine operational burden — unseal, HA, upgrades&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few honest notes on each, since the marketing pages won't give you these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud-native stores&lt;/strong&gt; are the default nobody regrets on cost or reliability, and AWS Systems Manager Parameter Store's standard tier in particular is the underrated option — plain-string parameters with KMS encryption, no per-secret monthly charge on the standard tier as of mid-2026 (Secrets Manager bills per secret per month plus API calls, so check your call pattern before assuming it's cheap). The pain is local development: your laptop now needs cloud credentials to boot the app, which is exactly the long-lived key you were trying to eliminate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doppler&lt;/strong&gt; is the one that handles the "same secret, five environments, three deploy targets" problem without a bespoke sync script, and its CLI injects secrets as environment variables for the duration of a process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;doppler run &lt;span class="nt"&gt;--project&lt;/span&gt; api &lt;span class="nt"&gt;--config&lt;/span&gt; dev &lt;span class="nt"&gt;--&lt;/span&gt; node server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing lands on disk, and &lt;code&gt;doppler run&lt;/code&gt; composes with whatever your app already expects. The drawback is real: you've added a network dependency to your startup path, and you should understand its cached-fallback behavior before you put it in front of production boots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1Password Secrets Automation&lt;/strong&gt; is the right pick when the same credential needs to be readable by a person during an incident and by a machine during a deploy, because it's one vault with one audit log for both. The CLI resolves &lt;code&gt;op://&lt;/code&gt; references at launch:&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;# .env.template — safe to commit; contains references, not values&lt;/span&gt;
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;op://prod/postgres/url
&lt;span class="nv"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;op://prod/stripe/secret_key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;op run &lt;span class="nt"&gt;--env-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.env.template &lt;span class="nt"&gt;--&lt;/span&gt; node server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Committing a template of &lt;em&gt;references&lt;/em&gt; is the part that quietly fixes onboarding — a new hire clones, runs, and gets the right values without anyone DM'ing them a file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vault (or OpenBao, the Linux Foundation fork created after HashiCorp's 2023 license change)&lt;/strong&gt; earns its complexity on exactly one feature: dynamic secrets. It can mint a Postgres user valid for an hour and revoke it automatically, so a leaked credential expires on its own. If nobody on your team wants to own unseal keys and HA topology, use the managed offering or don't use Vault — a badly-run Vault is worse than Parameter Store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vault is a correct answer to a question most small teams don't have yet; the question it answers is "how do I make leaked credentials expire by themselves."&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you get secrets into CI without storing secrets in CI?
&lt;/h2&gt;

&lt;p&gt;This is the part teams skip, and it's where the highest-value credentials sit. A long-lived cloud access key pasted into repository secrets is the single most valuable thing an attacker can get from your CI — it survives rotation of everything else.&lt;/p&gt;

&lt;p&gt;Use OIDC federation instead. GitHub Actions can exchange a short-lived workflow identity token for cloud credentials, so no static key exists to steal:&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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&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;aws-actions/configure-aws-credentials@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;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/deploy&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&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;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trust policy on that IAM role should pin the repository &lt;em&gt;and&lt;/em&gt; the branch or environment — a wildcard subject condition means any workflow in your org can assume it. GitLab CI, CircleCI, and Buildkite all have equivalent OIDC flows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your CI still holds a long-lived cloud key in 2026, fixing that beats every other item on this list.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is an encrypted-file approach (SOPS) actually right?
&lt;/h2&gt;

&lt;p&gt;SOPS encrypts only the &lt;em&gt;values&lt;/em&gt; in a YAML/JSON file, leaving keys readable, so a diff still shows which setting changed without revealing anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sops &lt;span class="nt"&gt;--encrypt&lt;/span&gt; &lt;span class="nt"&gt;--age&lt;/span&gt; age1ql3z... secrets.dev.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; secrets.dev.enc.yaml
sops &lt;span class="nt"&gt;--decrypt&lt;/span&gt; secrets.dev.enc.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That property — reviewable diffs — is the whole argument. It fits GitOps and Kubernetes flows where config already ships through git, and it works offline with no vendor in the boot path.&lt;/p&gt;

&lt;p&gt;The cost is that rotation means a commit, a merge, and a deploy, and every old value stays in git history forever. Once a secret has been in a repo, rotating it is the only real remediation — deleting the commit is not, since clones and forks keep the object. Treat SOPS as configuration-that-happens-to-be-sensitive, not as a credential vault.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;How should a small startup manage secrets across dev, staging, and production?&lt;/strong&gt;&lt;br&gt;
One store, three scopes, no files on laptops. Use your cloud provider's secret store if you're single-cloud, or Doppler/1Password if you aren't, and inject values into the process at launch instead of writing &lt;code&gt;.env&lt;/code&gt; files to disk. Validate every required variable at boot so a missing secret crashes instead of falling back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to commit a .env file if it's encrypted?&lt;/strong&gt;&lt;br&gt;
Encrypted with SOPS or git-crypt, yes, with two caveats: the decryption key must live outside the repo, and any value that has ever been committed in plaintext must be rotated, because git history and existing clones keep it permanently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need HashiCorp Vault for a five-person team?&lt;/strong&gt;&lt;br&gt;
Almost certainly not. Vault's payoff is dynamic, short-lived credentials and fine-grained policy; below that bar, a managed secret store plus OIDC in CI gives you most of the security benefit with none of the unseal-and-HA operational burden.&lt;/p&gt;

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

&lt;p&gt;Single-cloud teams should use their provider's secret store with OIDC in CI and stop there — it's the lowest-cost, lowest-drama option, and it removes the long-lived keys that actually get exploited. Teams deploying across several platforms, or handing credentials to people who don't touch infrastructure, get real time back from Doppler or 1Password's CLI injection. Reach for SOPS when sensitive config genuinely needs to be reviewed in pull requests, and for Vault or OpenBao only when you specifically want credentials that expire on their own. Whatever you choose, the boot-time validation and the CI key removal matter more than the vendor.&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/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/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;/ul&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>cicd</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Your Postgres Migration Runner Needs a Retry Contract, Not Just a Lock Timeout</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Mon, 17 Aug 2026 15:23:11 +0000</pubDate>
      <link>https://dev.to/libme/your-postgres-migration-runner-needs-a-retry-contract-not-just-a-lock-timeout-1oii</link>
      <guid>https://dev.to/libme/your-postgres-migration-runner-needs-a-retry-contract-not-just-a-lock-timeout-1oii</guid>
      <description>&lt;p&gt;Setting &lt;code&gt;lock_timeout&lt;/code&gt; on a migration keeps a blocked &lt;code&gt;ALTER TABLE&lt;/code&gt; from freezing your traffic, but on its own it just converts one outage into a flaky deploy. The complete version is five rules: exactly one migrator at a time (advisory lock), a short lock timeout scoped to the transaction, the blocker logged before every retry, jittered backoff so replicas don't wake in lockstep, and a wall-clock budget after which the deploy fails instead of retrying forever. A reader raised this on an earlier post about migration locks, and it's the part most runners get wrong.&lt;/p&gt;

&lt;p&gt;Everything here holds for Postgres 12 and up, as of mid-2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do several replicas try to run the same migration at once?
&lt;/h2&gt;

&lt;p&gt;Most deploy systems run migrations as a pre-start step in the application container. Roll out four replicas and you have four processes racing to apply the same DDL. Usually the migration table's own row lock hides this, and you never notice.&lt;/p&gt;

&lt;p&gt;You notice when the DDL is blocked. All four sit in the lock queue behind the same idle-in-transaction session. Then the blocker commits, all four wake, and whichever loses the race hits a duplicate-object error or, worse, a partially applied migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  column "shipped_at" of relation "orders" already exists
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the deploy is red for a reason that has nothing to do with the real problem. Worse, if you added deterministic exponential backoff — sleep 1s, 2s, 4s — every replica computes the &lt;em&gt;same&lt;/em&gt; delays, so they retry in unison and keep colliding on the same schedule. Backoff without jitter is a synchronized herd.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a blocked migration turns a harmless replica race into a deploy failure, so serialization has to happen before the retry logic, not after.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I make sure only one migrator runs at a time?
&lt;/h2&gt;

&lt;p&gt;Postgres advisory locks. They're application-defined locks on an arbitrary &lt;code&gt;bigint&lt;/code&gt; key, scoped to the database, with no table involved:&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;-- session-level: held until unlocked or the connection closes&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_try_advisory_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8675309&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;-- true = you are the migrator&lt;/span&gt;
&lt;span class="c1"&gt;-- ...run migrations...&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_advisory_unlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8675309&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;pg_try_advisory_lock&lt;/code&gt; (returns immediately) rather than &lt;code&gt;pg_advisory_lock&lt;/code&gt; (waits forever) so a losing replica can decide what to do rather than hanging. Two reasonable choices: exit 0 and let the winner apply the schema, or wait a bounded time if the replica cannot safely serve traffic against the old schema. Pick one deliberately — silently exiting 0 is a real footgun when the new code needs the new column.&lt;/p&gt;

&lt;p&gt;One deployment detail decides which lock scope you can use. If your connection goes through &lt;strong&gt;PgBouncer&lt;/strong&gt; in transaction pooling mode, session-level advisory locks are unsafe, because the connection returns to the pool between transactions while the lock is still attached to it. There, use &lt;code&gt;pg_advisory_xact_lock(8675309)&lt;/code&gt; inside the migration transaction, which Postgres releases automatically at commit or rollback.&lt;/p&gt;

&lt;p&gt;Some runners already do this for you, and it's worth checking before you build your own:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Runner&lt;/th&gt;
&lt;th&gt;Cross-process locking (as of mid-2026)&lt;/th&gt;
&lt;th&gt;Watch out for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Flyway&lt;/td&gt;
&lt;td&gt;Yes, database-level lock on Postgres&lt;/td&gt;
&lt;td&gt;Verify behavior when running through a transaction pooler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Liquibase&lt;/td&gt;
&lt;td&gt;Yes, via a &lt;code&gt;DATABASECHANGELOGLOCK&lt;/code&gt; row&lt;/td&gt;
&lt;td&gt;A killed run can leave the lock row set; needs manual release&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rails Active Record&lt;/td&gt;
&lt;td&gt;Yes, advisory lock by default&lt;/td&gt;
&lt;td&gt;Can be disabled in config; check it wasn't turned off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;golang-migrate&lt;/td&gt;
&lt;td&gt;Yes, advisory lock on the Postgres driver&lt;/td&gt;
&lt;td&gt;Lock is per-database, so shared databases share the lock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alembic&lt;/td&gt;
&lt;td&gt;No built-in lock&lt;/td&gt;
&lt;td&gt;Serialization is your job — wrap the runner yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you want serialization without writing a runner, &lt;strong&gt;Flyway&lt;/strong&gt; is the one that takes a database lock for you and fails the second process cleanly instead of letting it race.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: check whether your migration tool already serializes runs before adding your own advisory lock — two locking schemes are not safer than one.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where should lock_timeout actually be set?
&lt;/h2&gt;

&lt;p&gt;Inside the transaction, with &lt;code&gt;SET LOCAL&lt;/code&gt;, so it evaporates at commit:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'3s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;application_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'migrator:add_shipped_at'&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;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;shipped_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason to prefer &lt;code&gt;SET LOCAL&lt;/code&gt; over a plain &lt;code&gt;SET&lt;/code&gt; is pooling: a plain &lt;code&gt;SET&lt;/code&gt; sticks to the backend, and a pooled connection can hand that 3-second timeout to unrelated application queries later. The distinctive &lt;code&gt;application_name&lt;/code&gt; costs nothing and makes the blocker/waiter pair obvious in &lt;code&gt;pg_stat_activity&lt;/code&gt; at 2am.&lt;/p&gt;

&lt;p&gt;Two exceptions worth knowing. &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; cannot run inside a transaction block, so &lt;code&gt;SET LOCAL&lt;/code&gt; doesn't apply — issue a session-level &lt;code&gt;SET lock_timeout&lt;/code&gt; on that connection instead. And don't wrap &lt;code&gt;CONCURRENTLY&lt;/code&gt; work in a tight &lt;code&gt;statement_timeout&lt;/code&gt;: if the timeout kills it, you're left with an &lt;code&gt;INVALID&lt;/code&gt; index that you must drop before retrying.&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;indexrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_index&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;indisvalid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway: &lt;code&gt;SET LOCAL lock_timeout&lt;/code&gt; bounds lock acquisition for this transaction only, which is exactly the scope you want on a pooled connection.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How should the retry loop back off, and what should it log first?
&lt;/h2&gt;

&lt;p&gt;Full jitter — sleep a uniform random amount between zero and the current exponential cap — is what breaks the synchronized herd. And before each sleep, capture &lt;em&gt;who&lt;/em&gt; blocked you, because a retry that succeeds tells you nothing about the query that will block tomorrow's migration too.&lt;/p&gt;

&lt;p&gt;There's a catch people get wrong: you cannot call &lt;code&gt;pg_blocking_pids(pg_backend_pid())&lt;/code&gt; from the connection that is currently blocked — it's busy waiting. Sampling it live requires a second observer connection. The cheap approximation is to probe right after the &lt;code&gt;lock_timeout&lt;/code&gt; fires, from the same connection, for the oldest transactions holding locks on that 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;SELECT&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;application_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&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;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;a&lt;/span&gt;&lt;span class="p"&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;a&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;80&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="n"&gt;a&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_locks&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public.orders'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pg_backend_pid&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xact_start&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&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;Occasionally the blocker has already finished and you log nothing. That's an acceptable trade for not running an observer thread. Here is the whole contract in one runner, using psycopg 3:&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;random&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;

&lt;span class="n"&gt;LOCK_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8675309&lt;/span&gt;       &lt;span class="c1"&gt;# same constant in every replica
&lt;/span&gt;&lt;span class="n"&gt;BUDGET_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;     &lt;span class="c1"&gt;# hard stop for the whole migration step
&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;20.0&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_migration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ddl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;autocommit&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="n"&gt;application_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;migrator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&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;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&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;SELECT pg_try_advisory_lock(%s)&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="n"&gt;LOCK_KEY&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;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchone&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;another migrator holds the lock; nothing to do&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;BUDGET_SECONDS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&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;attempt&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;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&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;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&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;SET LOCAL lock_timeout = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3s&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&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="n"&gt;ddl&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;applied on attempt &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LockNotAvailable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;log_blockers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&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="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CAP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&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;if&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;deadline&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lock budget exhausted; failing the deploy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&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;blocked; retry &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&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;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&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;SELECT pg_advisory_unlock(%s)&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="n"&gt;LOCK_KEY&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;
        &lt;span class="n"&gt;conn&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that only &lt;code&gt;LockNotAvailable&lt;/code&gt; (SQLSTATE 55P03) is retried. A syntax error or a constraint violation is not a transient condition, and retrying it just burns the budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: retry only the lock-timeout error, sleep a random interval rather than a computed one, and log the blocker before every sleep.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When should the deploy just fail?
&lt;/h2&gt;

&lt;p&gt;When the budget runs out. A migration that has been blocked for five minutes is not waiting on a slow query; it's waiting on something structural — an idle-in-transaction connection from a pool with no &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt;, a BI tool holding a long read, a &lt;code&gt;pg_dump&lt;/code&gt; that overlaps your deploy window. Retrying past that point hides the diagnosis you actually need.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom in the retry log&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix, not a retry&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Blocker &lt;code&gt;state = idle in transaction&lt;/code&gt;, age growing&lt;/td&gt;
&lt;td&gt;App or pool leaking an open transaction&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blocker is a long analytics &lt;code&gt;SELECT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Reporting traffic on the primary&lt;/td&gt;
&lt;td&gt;Move reads to a replica, or migrate off-peak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blocker is another &lt;code&gt;migrator:*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Advisory lock missing or disabled&lt;/td&gt;
&lt;td&gt;Fix serialization first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No blocker found, still timing out&lt;/td&gt;
&lt;td&gt;Blocker is short but constant&lt;/td&gt;
&lt;td&gt;Raise &lt;code&gt;lock_timeout&lt;/code&gt; slightly, or use a quieter window&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the budget's job is to convert an invisible hang into a red deploy with the blocker's identity attached.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why does my Postgres migration fail with "canceling statement due to lock timeout"?&lt;/strong&gt;&lt;br&gt;
Because &lt;code&gt;lock_timeout&lt;/code&gt; is doing its job: your DDL waited longer than that limit for a lock another session held. That error means your app stayed up. Look at what held the lock — usually an idle-in-transaction connection or a long-running read — rather than raising the timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can two application replicas run database migrations at the same time?&lt;/strong&gt;&lt;br&gt;
Yes, unless something stops them. Serialize the runner with &lt;code&gt;pg_try_advisory_lock&lt;/code&gt; on a fixed key, or confirm your migration tool takes its own lock. Behind PgBouncer in transaction pooling mode, use &lt;code&gt;pg_advisory_xact_lock&lt;/code&gt; instead, since session-level locks outlive the transaction that took them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should migration retries use exponential backoff?&lt;/strong&gt;&lt;br&gt;
Use exponential backoff with full jitter — a random sleep between zero and the current cap. Deterministic delays make every replica wake at the same instant and contend again. Cap total retry time with a wall-clock budget and fail the deploy when it's exhausted.&lt;/p&gt;

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

&lt;p&gt;If your migrations already use &lt;code&gt;lock_timeout&lt;/code&gt; and a non-blocking two-step for rewrites, the missing piece is the runner around them. Add one migrator via advisory lock (or verify your tool has one), scope the timeout with &lt;code&gt;SET LOCAL&lt;/code&gt;, tag the session with a distinctive &lt;code&gt;application_name&lt;/code&gt;, log blockers before each jittered retry, and stop hard at a fixed budget. Teams on Flyway, Liquibase, Rails, or golang-migrate mostly need to verify the lock is on and add the budget; teams on Alembic or a hand-rolled script need the whole contract. The point isn't to make blocked migrations succeed — it's to make them fail fast, once, with the blocker named.&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-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;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/postman-vs-bruno-vs-hoppscotch-does-your-api-client-really-need-a-cloud-account-34bk"&gt;Postman vs Bruno vs Hoppscotch: Does Your API Client Really Need a Cloud Account?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Your Postgres Backups Are Untested Until You Restore One: A Drill for Small Teams</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Mon, 17 Aug 2026 15:20:20 +0000</pubDate>
      <link>https://dev.to/libme/your-postgres-backups-are-untested-until-you-restore-one-a-drill-for-small-teams-3paj</link>
      <guid>https://dev.to/libme/your-postgres-backups-are-untested-until-you-restore-one-a-drill-for-small-teams-3paj</guid>
      <description>&lt;p&gt;A backup job that exits 0 tells you a file was written. It does not tell you the file can become a running database with your schema, your extensions, and your roles intact. The only way to know is to restore it on a schedule and time yourself, and the first restore you ever attempt should not be during an outage.&lt;/p&gt;

&lt;p&gt;This is the drill I run — roughly monthly, and always after any change to the schema, the extension list, or the Postgres major version. It takes about twenty minutes once it's scripted, and every single time I've introduced it somewhere, the first run failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a backup that "succeeded" fail to restore?
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;pg_dump&lt;/code&gt; captures the contents of one database, not the environment it lived in. The three things it leaves behind are the three things that break your restore:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Roles are cluster-level, not database-level.&lt;/strong&gt; &lt;code&gt;pg_dump&lt;/code&gt; does not include &lt;code&gt;CREATE ROLE&lt;/code&gt;. Restore into a fresh cluster and you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pg_restore: error: could not execute query: ERROR:  role "app_user" does not exist
Command was: ALTER TABLE public.orders OWNER TO app_user;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Extensions must exist as installed binaries on the target machine.&lt;/strong&gt; The dump contains &lt;code&gt;CREATE EXTENSION vector;&lt;/code&gt;, but that only works if the target already has the pgvector shared library on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  could not open extension control file "/usr/share/postgresql/16/extension/vector.control": No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the one that bites people who dump from a managed provider with extensions preinstalled and restore into a stock &lt;code&gt;postgres:16&lt;/code&gt; container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client and server versions have to line up.&lt;/strong&gt; &lt;code&gt;pg_dump&lt;/code&gt; refuses to dump from a server newer than itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pg_dump: error: server version: 16.3; pg_dump version: 15.6
pg_dump: error: aborting because of server version mismatch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure mode that actually hurts is silent: your cron box quietly ships an older &lt;code&gt;pg_dump&lt;/code&gt; than your upgraded server, the job starts failing, and nobody reads the log because nothing pages on a backup job. Alert on backup &lt;em&gt;failure and staleness&lt;/em&gt;, not just on failure.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A backup job's exit code proves a file exists; only a restore proves the file is a database.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;Five steps, all scriptable, all runnable on a laptop.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pull the most recent backup artifact — from object storage, not from a copy sitting on the same host as production.&lt;/li&gt;
&lt;li&gt;Start a throwaway Postgres of the &lt;strong&gt;same major version&lt;/strong&gt; as production.&lt;/li&gt;
&lt;li&gt;Restore into it with errors treated as fatal.&lt;/li&gt;
&lt;li&gt;Run assertions: row counts on your three or four most important tables, plus a query that exercises an extension.&lt;/li&gt;
&lt;li&gt;Record the wall-clock time and tear it down.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the core of it. Taking the dump:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

pg_dump &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;custom &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-privileges&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"backup_&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y%m%dT%H%M%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.dump"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--no-owner&lt;/code&gt; and &lt;code&gt;--no-privileges&lt;/code&gt; are what make the dump portable: ownership statements get dropped, so the restore doesn't demand that &lt;code&gt;app_user&lt;/code&gt; exists on the target. You then re-apply grants from your migration tooling, where they belong. Keep roles in version control as SQL; do not rely on them surviving in a dump.&lt;/p&gt;

&lt;p&gt;The drill itself:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;DUMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;PGVER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PGVER&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;16&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;CONTAINER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"restore-drill-&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;

docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;drill &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 55432:5432 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"pgvector/pgvector:pg&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PGVER&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;until &lt;/span&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; pg_isready &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-q&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;sleep &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PGPASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;drill
&lt;span class="nv"&gt;CONN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"postgresql://postgres@localhost:55432/postgres"&lt;/span&gt;

pg_restore &lt;span class="nt"&gt;--dbname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--exit-on-error&lt;/span&gt; &lt;span class="nt"&gt;--jobs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DUMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

psql &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;-f&lt;/span&gt; drill_assertions.sql

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"restore completed in &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; start &lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;s"&lt;/span&gt;
docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two flags carry most of the weight. &lt;code&gt;--exit-on-error&lt;/code&gt; is the important one: &lt;strong&gt;by default &lt;code&gt;pg_restore&lt;/code&gt; prints errors, keeps going, and exits 0&lt;/strong&gt;, which means an unattended restore check without it will happily report success on a half-populated database. &lt;code&gt;--jobs=4&lt;/code&gt; parallelizes table data and index builds, and it only works with the custom or directory formats — another reason to stop using plain SQL dumps for anything large.&lt;/p&gt;

&lt;p&gt;The assertions file is deliberately boring, and it should fail loudly:&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="err"&gt;\&lt;/span&gt;&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;ON_ERROR_STOP&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt;

&lt;span class="c1"&gt;-- structural: does the extension work, not just exist?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'[1,2,3]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'[3,2,1]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;distance_check&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- volumetric: catch a restore that "worked" but landed empty&lt;/span&gt;
&lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt; &lt;span class="n"&gt;n&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;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
    &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;EXCEPTION&lt;/span&gt; &lt;span class="s1"&gt;'orders table has only % rows — restore is suspect'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&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="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- freshness: how much data would we actually have lost?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&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;data_age&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last query is the one worth reading out loud in a team channel. It converts an abstract retention policy into a number: &lt;em&gt;if we restored right now, we would be missing this much.&lt;/em&gt; That number is your real RPO, and it is usually worse than whatever the backup docs implied.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Assert on row counts and data age, not on the restore's exit code — an empty database restores perfectly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Nightly dumps or point-in-time recovery?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pg_dump&lt;/code&gt; gives you a consistent snapshot and nothing between snapshots. If it runs at 03:00 and you lose the primary at 17:00, you have lost fourteen hours. Point-in-time recovery closes that gap by shipping the write-ahead log continuously, so you can replay to a chosen moment — including "one second before that &lt;code&gt;DELETE&lt;/code&gt; without a &lt;code&gt;WHERE&lt;/code&gt;."&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;Typical RPO&lt;/th&gt;
&lt;th&gt;Restore complexity&lt;/th&gt;
&lt;th&gt;Where it fits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pg_dump&lt;/code&gt; custom format to object storage&lt;/td&gt;
&lt;td&gt;Since last dump (hours)&lt;/td&gt;
&lt;td&gt;Low — one command&lt;/td&gt;
&lt;td&gt;Side projects, small internal apps, portable migrations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed provider snapshots + PITR&lt;/td&gt;
&lt;td&gt;Seconds to minutes&lt;/td&gt;
&lt;td&gt;Low, but provider-shaped&lt;/td&gt;
&lt;td&gt;Anything on RDS, Cloud SQL, Supabase, Neon, Crunchy Bridge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pgBackRest or WAL-G to your own bucket&lt;/td&gt;
&lt;td&gt;Seconds to minutes&lt;/td&gt;
&lt;td&gt;Medium — real config, real ops&lt;/td&gt;
&lt;td&gt;Self-hosted Postgres you intend to keep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Physical replica only&lt;/td&gt;
&lt;td&gt;Near zero for hardware loss&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Not a backup — replicates your &lt;code&gt;DROP TABLE&lt;/code&gt; faithfully&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the trap I see most often. A standby protects you from a dead machine; it does not protect you from a bad migration, because the destructive statement replicates in milliseconds. Keep both.&lt;/p&gt;

&lt;p&gt;If you self-host and want continuous archiving without writing your own WAL shipping, pgBackRest is the option that handles full/differential/incremental backups, parallel compression, and retention expiry with one config file and one command — at the cost of a genuinely non-trivial setup pass and a config format you will have to read the docs for every time you touch it. WAL-G is the leaner alternative when you want to push straight to S3-compatible storage with minimal moving parts, though you'll find fewer worked examples when something goes wrong. On the managed side, Neon's branching turns a restore drill into creating a branch from a past timestamp and pointing a test connection string at it, which is the lowest-friction version of this whole workflow — the constraint being that it's provider-specific, so you should still keep an independent logical dump if you ever want to leave.&lt;/p&gt;

&lt;p&gt;Whatever you pick, store backups in an account or bucket that your application's credentials cannot delete. Ransomware and a bad &lt;code&gt;terraform destroy&lt;/code&gt; fail the same way.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A replica is not a backup, and a backup you cannot restore without the original provider is a hostage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How often should you run the drill?
&lt;/h2&gt;

&lt;p&gt;Monthly is a reasonable floor for a small team. Run it additionally after: a Postgres major version upgrade, adding or removing an extension, changing the backup tool or its flags, and any change to who owns the storage bucket. Wire it into CI on a schedule if you can — a weekly GitHub Actions job that restores yesterday's dump into a service container and runs the assertions gives you a red build instead of a discovery at 2 a.m.&lt;/p&gt;

&lt;p&gt;Track exactly two numbers over time: minutes to a usable database (your RTO) and the &lt;code&gt;data_age&lt;/code&gt; from the assertions (your RPO). If either number surprises someone on the team, you've found the actual gap. As of mid-2026 I've never seen a first drill where both numbers matched what people assumed.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;How do I test a Postgres backup without touching production?&lt;/strong&gt;&lt;br&gt;
Restore the dump into a disposable container on a non-production port, run assertions against it, then destroy the container. Nothing in the drill connects to production except the read that fetched the backup file from object storage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does pg_restore exit 0 even though it printed errors?&lt;/strong&gt;&lt;br&gt;
Because &lt;code&gt;pg_restore&lt;/code&gt; treats most errors as non-fatal by default so it can restore as much as possible. Pass &lt;code&gt;--exit-on-error&lt;/code&gt; (and &lt;code&gt;--single-transaction&lt;/code&gt; if you want all-or-nothing) whenever a script is checking the result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a read replica enough of a backup for a small app?&lt;/strong&gt;&lt;br&gt;
No. A replica protects against losing a machine but faithfully replicates destructive SQL such as a bad &lt;code&gt;DELETE&lt;/code&gt; or migration. You need point-in-time recovery or periodic dumps to recover from a mistake you made yourself.&lt;/p&gt;

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

&lt;p&gt;If you run a side project or a small internal app, nightly &lt;code&gt;pg_dump --format=custom --no-owner&lt;/code&gt; to object storage plus a monthly scripted restore drill is enough, and it's an afternoon of work. Once real customer data is involved, move to continuous archiving — your provider's PITR if you're managed, pgBackRest or WAL-G if you're self-hosted — and keep the logical dump as your escape hatch from the provider. Never count a physical replica as a backup. And measure the drill: the restore you have never timed is the one that takes three hours on the worst possible day.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/instrument-a-nodejs-app-with-opentelemetry-in-an-afternoon-dll"&gt;Instrument a Node.js App with OpenTelemetry in an Afternoon&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/vercel-vs-netlify-vs-cloudflare-pages-where-your-side-project-should-actually-live-1j30"&gt;Vercel vs Netlify vs Cloudflare Pages: Where Your Side Project Should Actually Live&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>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>backend</category>
    </item>
  </channel>
</rss>
