<?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>FastAPI Latency Spikes Under Load: How One Blocking Call in an async Endpoint Stalls Every Request</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sat, 24 Oct 2026 22:08:17 +0000</pubDate>
      <link>https://dev.to/libme/fastapi-latency-spikes-under-load-how-one-blocking-call-in-an-async-endpoint-stalls-every-request-29e1</link>
      <guid>https://dev.to/libme/fastapi-latency-spikes-under-load-how-one-blocking-call-in-an-async-endpoint-stalls-every-request-29e1</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; An &lt;code&gt;async def&lt;/code&gt; endpoint runs directly on the event loop. If it calls anything that blocks (&lt;code&gt;time.sleep&lt;/code&gt;, &lt;code&gt;requests.get&lt;/code&gt;, a sync database driver, heavy CPU work), the loop cannot serve any other request until that call returns. The fix is to either declare the endpoint with plain &lt;code&gt;def&lt;/code&gt; so the framework runs it in a thread pool, push the blocking call into a thread with &lt;code&gt;asyncio.to_thread&lt;/code&gt;, or switch to a genuinely async client. The symptom that sends people searching is odd: health checks time out and p99 explodes while CPU looks fine.&lt;/p&gt;

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

&lt;p&gt;The pattern I see over and over: an endpoint is fast in isolation, and a load test or a traffic burst makes &lt;em&gt;unrelated&lt;/em&gt; endpoints slow, including &lt;code&gt;/health&lt;/code&gt;. Uptime probes fail, the load balancer marks the instance unhealthy, and the instance restarts under load, which makes things worse.&lt;/p&gt;

&lt;p&gt;Here is a minimal reproduction. Python 3.11+, with &lt;code&gt;fastapi&lt;/code&gt;, &lt;code&gt;uvicorn&lt;/code&gt; and &lt;code&gt;httpx&lt;/code&gt; installed:&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;# app.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/health&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;health&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="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="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/slow-async&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;slow_async&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.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# blocks the event loop
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/slow-sync&lt;/span&gt;&lt;span class="sh"&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;slow_sync&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.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# runs in a worker thread
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;uvicorn app:app&lt;/code&gt; and fire ten concurrent requests at each slow route. Against &lt;code&gt;/slow-async&lt;/code&gt;, the ten requests are served one after another, so the last one waits roughly ten times 0.5 seconds. That is arithmetic, not a benchmark: a single-threaded loop can only do one blocking call at a time. During that window &lt;code&gt;/health&lt;/code&gt; also waits, because it is queued behind them on the same loop.&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;# hammer.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&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;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&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="nb"&gt;str&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="nb"&gt;int&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_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;http://127.0.0.1:8000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&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;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;start&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;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="c1"&gt;# fire n slow requests, then probe /health while they are in flight
&lt;/span&gt;        &lt;span class="n"&gt;slow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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;path&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="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&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.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;h0&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;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;c&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;/health&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;/health took &lt;/span&gt;&lt;span class="si"&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;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;h0&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;s while &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; was busy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;slow&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;all &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; done in &lt;/span&gt;&lt;span class="si"&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;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;start&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;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;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;python hammer.py /slow-async&lt;/code&gt; and then &lt;code&gt;python hammer.py /slow-sync&lt;/code&gt;. The first shows &lt;code&gt;/health&lt;/code&gt; stuck for seconds; the second answers it almost immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: &lt;code&gt;async def&lt;/code&gt; is a promise that you never block, and the framework cannot enforce it for you.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does &lt;code&gt;def&lt;/code&gt; work when &lt;code&gt;async def&lt;/code&gt; doesn't?
&lt;/h2&gt;

&lt;p&gt;FastAPI (via Starlette and AnyIO) treats the two kinds differently. A plain &lt;code&gt;def&lt;/code&gt; endpoint is dispatched to a worker thread pool, so a blocking call occupies a thread, not the loop. An &lt;code&gt;async def&lt;/code&gt; endpoint is awaited on the loop itself. As of late 2026 the AnyIO thread limiter defaults to 40 concurrent threads, so a &lt;code&gt;def&lt;/code&gt; endpoint is not free of limits either: the 41st concurrent blocking request queues.&lt;/p&gt;

&lt;p&gt;The common mistake is the well-meaning "everything should be async" refactor. Converting &lt;code&gt;def&lt;/code&gt; to &lt;code&gt;async def&lt;/code&gt; without converting the calls inside it makes the endpoint &lt;em&gt;worse&lt;/em&gt;, because you moved it off the thread pool onto the loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: converting &lt;code&gt;def&lt;/code&gt; to &lt;code&gt;async def&lt;/code&gt; is a performance regression unless every call inside it was converted too.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I find the blocking call?
&lt;/h2&gt;

&lt;p&gt;Do not guess. Two tools find it quickly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. asyncio debug mode.&lt;/strong&gt; Start the server with &lt;code&gt;PYTHONASYNCIODEBUG=1 uvicorn app:app&lt;/code&gt;. The loop logs any callback that holds it longer than &lt;code&gt;slow_callback_duration&lt;/code&gt; (0.1 s by default), with the task and the coroutine involved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Executing &amp;lt;Task pending name='Task-7' coro=&amp;lt;slow_async() running at app.py:13&amp;gt; ...&amp;gt; took 0.501 seconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line names the endpoint. Debug mode adds overhead, so use it in staging or a local repro, not on a hot production box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A sampling profiler.&lt;/strong&gt; If you cannot reproduce locally, &lt;code&gt;py-spy dump --pid &amp;lt;pid&amp;gt;&lt;/code&gt; shows where each thread currently is. When the loop thread sits in &lt;code&gt;time.sleep&lt;/code&gt;, &lt;code&gt;socket.recv&lt;/code&gt;, or a driver's &lt;code&gt;execute&lt;/code&gt;, that is your culprit. The honest limitation of &lt;code&gt;py-spy&lt;/code&gt;: it needs permission to attach to the process (ptrace on Linux, root on macOS), which is often locked down in containers.&lt;/p&gt;

&lt;p&gt;The usual suspects, in the order I find them: &lt;code&gt;requests&lt;/code&gt; inside an async handler, a sync ORM session or &lt;code&gt;psycopg2&lt;/code&gt; call, &lt;code&gt;open().read()&lt;/code&gt; on large files, &lt;code&gt;json.loads&lt;/code&gt; on multi-megabyte payloads, password hashing (bcrypt/argon2 are deliberately slow), and image or PDF processing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the slow-callback log turns "something is blocking" into a file and line number in one run.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which fix should I use?
&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;Trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Handler is mostly sync code (sync ORM, &lt;code&gt;requests&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Change &lt;code&gt;async def&lt;/code&gt; to &lt;code&gt;def&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Limited by the thread pool size; no &lt;code&gt;await&lt;/code&gt; inside&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One blocking call inside an otherwise async handler&lt;/td&gt;
&lt;td&gt;&lt;code&gt;await asyncio.to_thread(fn, ...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same thread pool limits; code must be thread-safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP calls to other services&lt;/td&gt;
&lt;td&gt;Use an async client such as &lt;code&gt;httpx.AsyncClient&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Needs one shared client, not one per request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU-bound work (hashing, image resize, big parsing)&lt;/td&gt;
&lt;td&gt;Process pool or a job queue&lt;/td&gt;
&lt;td&gt;Serialization cost; more moving parts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the one-call case:&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;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&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;bytes&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;bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hashpw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bcrypt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gensalt&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/signup&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&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;hashed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hash_len&lt;/span&gt;&lt;span class="sh"&gt;"&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;hashed&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For outbound HTTP, create the client once at startup rather than per request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;contextlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asynccontextmanager&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;

&lt;span class="nd"&gt;@asynccontextmanager&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lifespan&lt;/span&gt;&lt;span class="p"&gt;(&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;FastAPI&lt;/span&gt;&lt;span class="p"&gt;):&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http&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="nc"&gt;AsyncClient&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="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt;
    &lt;span class="k"&gt;await&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;aclose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lifespan&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lifespan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/proxy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;app&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="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&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="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want an HTTP client that works in both sync and async code with the same API, &lt;code&gt;httpx&lt;/code&gt; is the one I reach for; its drawback is that it is a bit slower than lower-level clients such as &lt;code&gt;aiohttp&lt;/code&gt; on very high request rates, and the default timeouts differ from &lt;code&gt;requests&lt;/code&gt;, so set them explicitly.&lt;/p&gt;

&lt;p&gt;CPU-bound work is the case threads do not rescue: threads share the GIL, so heavy pure-Python computation in &lt;code&gt;to_thread&lt;/code&gt; still starves other Python threads (C extensions that release the GIL, such as bcrypt, are fine). Use &lt;code&gt;ProcessPoolExecutor&lt;/code&gt; via &lt;code&gt;loop.run_in_executor&lt;/code&gt; or hand it to a worker queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: match the fix to the kind of blocking: threads for I/O and GIL-releasing libraries, processes or queues for pure-Python CPU work.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Can running more workers hide the problem?
&lt;/h2&gt;

&lt;p&gt;Yes, and that is why it survives so long. &lt;code&gt;uvicorn --workers 4&lt;/code&gt; (or Gunicorn with Uvicorn workers) gives you four independent loops, so one stuck loop no longer stalls everything. But four workers each blocked by one slow request is just a smaller version of the same outage. More workers are good capacity planning and bad bug-hiding; fix the block first, then size the workers.&lt;/p&gt;

&lt;p&gt;If you want the guard rail rather than the fix, Uvicorn's &lt;code&gt;--timeout-keep-alive&lt;/code&gt; and your proxy's upstream timeout will at least turn a silent stall into a visible 504, which is easier to alert on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: extra workers raise the number of requests a blocking call can hurt at once; they do not remove the block.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why is my FastAPI app slow when I use async def?&lt;/strong&gt;&lt;br&gt;
Almost always because something inside the handler blocks the event loop, such as &lt;code&gt;requests&lt;/code&gt;, a sync database driver, or &lt;code&gt;time.sleep&lt;/code&gt;. Run with &lt;code&gt;PYTHONASYNCIODEBUG=1&lt;/code&gt; to see which coroutine holds the loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I use def or async def in FastAPI?&lt;/strong&gt;&lt;br&gt;
Use &lt;code&gt;async def&lt;/code&gt; only when every I/O call inside it is awaited. Use plain &lt;code&gt;def&lt;/code&gt; when you depend on sync libraries; FastAPI runs it in a thread pool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does asyncio.to_thread make blocking code non-blocking?&lt;/strong&gt;&lt;br&gt;
It moves the call off the event loop onto a worker thread, so other requests keep being served. It does not speed up the call, and pure-Python CPU work still contends for the GIL.&lt;/p&gt;

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

&lt;p&gt;If you use sync libraries, write plain &lt;code&gt;def&lt;/code&gt; endpoints and stop worrying. If you are committed to &lt;code&gt;async def&lt;/code&gt;, treat every library call as guilty until proven awaitable, and keep asyncio debug mode in your staging setup so a new blocking dependency gets caught in review rather than at 2 a.m. For pure CPU work, move it out of the web process entirely.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/an-ai-assisted-code-review-pipeline-that-catches-what-humans-skim-past-5hc0"&gt;An AI-Assisted Code Review Pipeline That Catches What Humans Skim Past&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/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;li&gt;&lt;a href="https://dev.to/libme/your-logging-bill-is-a-volume-problem-model-the-cost-before-you-switch-vendors-4hmh"&gt;Your Logging Bill Is a Volume Problem: Model the Cost Before You Switch Vendors&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>backend</category>
      <category>performance</category>
      <category>api</category>
    </item>
    <item>
      <title>GitHub Push Rejected: "File Exceeds 100 MB" (GH001) — Fix It With filter-repo or LFS</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Fri, 23 Oct 2026 22:26:52 +0000</pubDate>
      <link>https://dev.to/libme/github-push-rejected-file-exceeds-100-mb-gh001-fix-it-with-filter-repo-or-lfs-f31</link>
      <guid>https://dev.to/libme/github-push-rejected-file-exceeds-100-mb-gh001-fix-it-with-filter-repo-or-lfs-f31</guid>
      <description>&lt;p&gt;Deleting the big file in a new commit does not fix this error, because Git still carries the file in an earlier commit and GitHub rejects the whole push. You have to either rewrite history so the blob never existed (&lt;code&gt;git filter-repo&lt;/code&gt;), or move the file to Git LFS and rewrite history so old commits point to LFS instead (&lt;code&gt;git lfs migrate&lt;/code&gt;). Pick filter-repo if the file should not be in the repo at all, LFS if it should be versioned but is legitimately large.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;remote: error: Trace: 3f1c...
remote: error: See https://gh.io/lfs for more information.
remote: error: File data/embeddings.bin is 143.21 MB; this exceeds GitHub's file size limit of 100.00 MB
remote: error: GH001: Large files detected. You may want to try Git Large File Storage - https://git-lfs.github.com.
 ! [remote rejected] main -&amp;gt; main (pre-receive hook declined)
error: failed to push some refs to 'github.com:you/project.git'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As of late 2026, GitHub prints a warning for files over 50 MB and blocks pushes containing files over 100 MB. The check runs on every blob in the commits you are pushing, not just the tip of your branch. That is the part that surprises people.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: GH001 is about every commit in the push, so the fix has to change history, not just the working tree.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why doesn't &lt;code&gt;git rm&lt;/code&gt; and a new commit fix it?
&lt;/h2&gt;

&lt;p&gt;I have watched this dead-end happen more than once: the developer runs &lt;code&gt;git rm data/embeddings.bin&lt;/code&gt;, commits, pushes, and gets the identical error. The file is deleted from the latest snapshot, but the commit that added it is still an ancestor of your branch, and it is part of what you are pushing.&lt;/p&gt;

&lt;p&gt;Other dead ends that look plausible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;git commit --amend&lt;/code&gt;&lt;/strong&gt; only works if the big file is in the very last commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding it to &lt;code&gt;.gitignore&lt;/code&gt;&lt;/strong&gt; after the fact does nothing for a file already tracked or already committed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;git reset --soft&lt;/code&gt; and recommit&lt;/strong&gt; works for unpushed local history and is actually fine for a small number of commits, but gets tedious fast and loses your commit messages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;First, find out which commit and which path is the problem:&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;# list the 10 largest blobs anywhere in history&lt;/span&gt;
git rev-list &lt;span class="nt"&gt;--objects&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | git cat-file &lt;span class="nt"&gt;--batch-check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'%(objecttype) %(objectname) %(objectsize) %(rest)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'$1 == "blob" {print $3, $4}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first column is bytes, the second is the path. If the same path shows up in multiple versions, every version is in history and every one over the limit will be rejected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: before touching anything, list the largest blobs in history so you know whether you are removing one file or twenty.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Option A: Remove the file entirely with git filter-repo
&lt;/h2&gt;

&lt;p&gt;Use this when the file was committed by accident (a dataset, a build artifact, a database dump, a model checkpoint) and does not belong in the repository.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git filter-repo&lt;/code&gt; is the tool the Git project itself recommends over the older &lt;code&gt;git filter-branch&lt;/code&gt;. Install it with &lt;code&gt;pip install git-filter-repo&lt;/code&gt; or your package manager. Work on a fresh clone, because filter-repo deliberately refuses to run on a repo that looks like it has remotes and local work you could lose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone git@github.com:you/project.git project-clean
&lt;span class="nb"&gt;cd &lt;/span&gt;project-clean

&lt;span class="c"&gt;# remove one path from all history&lt;/span&gt;
git filter-repo &lt;span class="nt"&gt;--path&lt;/span&gt; data/embeddings.bin &lt;span class="nt"&gt;--invert-paths&lt;/span&gt;

&lt;span class="c"&gt;# or: remove every blob over a size threshold, whatever its path&lt;/span&gt;
git filter-repo &lt;span class="nt"&gt;--strip-blobs-bigger-than&lt;/span&gt; 50M
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;filter-repo removes the &lt;code&gt;origin&lt;/code&gt; remote as a safety measure, so re-add it and force-push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git remote add origin git@github.com:you/project.git
git push &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt; origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two honest drawbacks. First, this rewrites every commit hash from the first affected commit onward, so every collaborator must re-clone or hard-reset, and open pull requests will break. Second, if the file contained a secret, rewriting history on GitHub does not make the secret safe: assume it leaked and rotate it. Cached views of old commits can persist on the platform even after a force push.&lt;/p&gt;

&lt;p&gt;If you want a single-purpose tool with a simpler interface, BFG Repo-Cleaner does the same job, but it is less flexible with path filtering than filter-repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: use &lt;code&gt;git filter-repo --invert-paths&lt;/code&gt; when the file was a mistake, and treat every collaborator clone as needing a reset afterward.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Option B: Keep the file but move it to Git LFS
&lt;/h2&gt;

&lt;p&gt;Use this when the file is a legitimate part of the project, like design assets, test fixtures, or a small model, and you want it versioned.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git lfs &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;span class="c"&gt;# rewrite ALL history so matching files become LFS pointers&lt;/span&gt;
git lfs migrate import &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.bin,data/*.parquet"&lt;/span&gt; &lt;span class="nt"&gt;--everything&lt;/span&gt;
git push &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt; origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--everything&lt;/code&gt; rewrites every branch and tag; without it only the current branch is migrated and the other refs still contain the big blobs. Check the result with &lt;code&gt;git lfs ls-files&lt;/code&gt;, and confirm &lt;code&gt;.gitattributes&lt;/code&gt; now contains the tracking rules, since that file has to be committed for teammates to get the same behavior.&lt;/p&gt;

&lt;p&gt;The drawback is that LFS is a separate storage and bandwidth quota on GitHub. The free allowance is limited, large files in CI re-download on every checkout unless you cache them, and forks have their own quirks with LFS objects. Check GitHub's current LFS billing page before committing a multi-gigabyte dataset to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: LFS keeps big files versioned, but you are trading a hard push error for a storage and bandwidth quota you now have to watch.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which fix should you use?
&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;Best fix&lt;/th&gt;
&lt;th&gt;History rewritten?&lt;/th&gt;
&lt;th&gt;Ongoing cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Accidental commit of dump, build output, &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;node_modules&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git filter-repo --invert-paths&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Several oversized blobs, unsure which&lt;/td&gt;
&lt;td&gt;&lt;code&gt;filter-repo --strip-blobs-bigger-than&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large assets that must be versioned&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git lfs migrate import&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;LFS quota&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large file only in unpushed local commits&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git reset --soft&lt;/code&gt; and recommit&lt;/td&gt;
&lt;td&gt;Local only&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dataset or model that changes often&lt;/td&gt;
&lt;td&gt;Object storage (S3, R2) plus a download script&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Storage fees&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the one I would choose most often for ML-ish repos: store the artifact in a bucket and keep a small script plus a checksum in Git. It keeps clones fast and leaves no history problem to fix later.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you stop this from happening again?
&lt;/h2&gt;

&lt;p&gt;Add the pattern to &lt;code&gt;.gitignore&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; the file exists, and add a pre-commit check so the failure happens on your laptop instead of at push time:&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="c"&gt;# .git/hooks/pre-commit — block staged files over 50 MB&lt;/span&gt;
&lt;span class="nv"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="m"&gt;50&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;git diff &lt;span class="nt"&gt;--cached&lt;/span&gt; &lt;span class="nt"&gt;--name-only&lt;/span&gt; &lt;span class="nt"&gt;--diff-filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;AM&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$limit&lt;/span&gt;&lt;span class="s2"&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;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Refusing to commit &lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="nv"&gt;$size&lt;/span&gt;&lt;span class="s2"&gt; bytes, limit &lt;/span&gt;&lt;span class="nv"&gt;$limit&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
  &lt;span class="k"&gt;fi
done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make it executable with &lt;code&gt;chmod +x .git/hooks/pre-commit&lt;/code&gt;. Hooks in &lt;code&gt;.git/&lt;/code&gt; are not shared, so for a team use a framework such as pre-commit, which has a built-in large-file check.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why does GitHub reject my push after I deleted the large file?&lt;/strong&gt;&lt;br&gt;
Because the commit that added the file is still in your branch history, and GitHub scans every commit being pushed. You must rewrite history with &lt;code&gt;git filter-repo&lt;/code&gt; or &lt;code&gt;git lfs migrate&lt;/code&gt; to remove the blob from those commits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is GitHub's maximum file size for a regular Git push?&lt;/strong&gt;&lt;br&gt;
GitHub blocks files larger than 100 MB and warns above 50 MB, as of late 2026. Larger files need Git LFS or external storage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to force-push after running git filter-repo?&lt;/strong&gt;&lt;br&gt;
It is safe for your repo if you use &lt;code&gt;--force-with-lease&lt;/code&gt; and coordinate with collaborators, who will need to re-clone. It is not a fix for leaked secrets, which must be rotated regardless.&lt;/p&gt;

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

&lt;p&gt;If the file should never have been committed, run &lt;code&gt;git filter-repo&lt;/code&gt; on a fresh clone and force-push once. If you genuinely need large files versioned alongside code, Git LFS is the standard route on GitHub, with a quota to monitor. For datasets and model weights that change often, keep them in object storage and let Git hold only the script that fetches them.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/postgres-as-a-job-queue-vs-redis-vs-sqs-when-does-just-use-your-database-stop-working-1f24"&gt;Postgres as a Job Queue vs Redis vs SQS: When Does "Just Use Your Database" Stop Working?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/railway-vs-render-vs-flyio-deploying-a-hobby-app-without-a-devops-team-10d3"&gt;Railway vs Render vs Fly.io: Deploying a Hobby App Without a DevOps Team&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/monorepo-or-polyrepo-for-a-three-person-startup-a-decision-framework-51nd"&gt;Monorepo or Polyrepo for a Three-Person Startup? A Decision Framework&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>github</category>
      <category>tutorial</category>
      <category>tooling</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Sentry vs GlitchTip vs Self-Hosted Sentry: Error Tracking for a Small Team When the Event Quota Keeps Running Out</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Thu, 22 Oct 2026 21:31:45 +0000</pubDate>
      <link>https://dev.to/libme/sentry-vs-glitchtip-vs-self-hosted-sentry-error-tracking-for-a-small-team-when-the-event-quota-2bnn</link>
      <guid>https://dev.to/libme/sentry-vs-glitchtip-vs-self-hosted-sentry-error-tracking-for-a-small-team-when-the-event-quota-2bnn</guid>
      <description>&lt;p&gt;For most small teams, hosted Sentry is the right default, and the thing that decides whether it stays affordable is not the vendor but how many duplicate events your app emits. GlitchTip is a credible cheaper alternative if you only need error tracking and are happy with the Sentry SDKs. Self-hosting full Sentry is the option I would pick last, because the cost moves from a bill to your on-call time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the error quota run out in the first week?
&lt;/h2&gt;

&lt;p&gt;The usual story: you add error tracking, everything looks calm, then a downstream API goes down for twenty minutes. A retry loop or a queue consumer throws the same exception on every attempt, and a plan priced by event volume absorbs thousands of identical events. The dashboard shows one issue with a huge count. The quota is gone and the useful errors that arrive afterwards are dropped or throttled.&lt;/p&gt;

&lt;p&gt;Hosted tools price by events (or similar units) per month, and exact tiers change, so check the current pricing page rather than trusting a blog post. What does not change is the shape of the problem: volume is driven by your worst incident, not your normal day. A single noisy failure mode can cost more quota than a month of ordinary errors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: error-tracking cost is set by your noisiest incident, so cap duplicates in the client before comparing vendors.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix the volume first: client-side dedupe and sampling
&lt;/h2&gt;

&lt;p&gt;Before you switch tools, drop repeats at the source. The Sentry JavaScript/Node SDK exposes &lt;code&gt;beforeSend&lt;/code&gt;, &lt;code&gt;ignoreErrors&lt;/code&gt; and &lt;code&gt;sampleRate&lt;/code&gt;, and GlitchTip accepts events from the same SDKs, so this work carries over if you move.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Sentry&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;@sentry/node&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;WINDOW_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&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;MAX_PER_WINDOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;function&lt;/span&gt; &lt;span class="nf"&gt;fingerprintOf&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;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ErrorEvent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;ex&lt;/span&gt; &lt;span class="o"&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;exception&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;values&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;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&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;ex&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&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="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="c1"&gt;// Network noise you cannot act on&lt;/span&gt;
  &lt;span class="na"&gt;ignoreErrors&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;ECONNRESET&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;AbortError&lt;/span&gt;&lt;span class="dl"&gt;"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fingerprintOf&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seen&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="nx"&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="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;WINDOW_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;seen&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="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&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;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&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="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="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// Returning null drops the event before it counts against quota&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_PER_WINDOW&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two caveats from using this pattern. The &lt;code&gt;Map&lt;/code&gt; grows with distinct messages, so if your error text embeds IDs you should key on the type and a normalized message, or add periodic cleanup. And a per-process limiter is per process: ten replicas each send their own five events per minute. That is still a large reduction from unbounded.&lt;/p&gt;

&lt;p&gt;Hosted Sentry also offers server-side controls such as spike protection and inbound filters (as of late 2026 these are plan-dependent in places, so verify on your tier). Server-side filtering is a safety net, but dropping events in the client means they never reach the quota at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a ten-line &lt;code&gt;beforeSend&lt;/code&gt; limiter removes the failure mode that makes every error-tracking plan look too small.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sentry vs GlitchTip vs self-hosted Sentry: what actually differs?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Hosted Sentry&lt;/th&gt;
&lt;th&gt;GlitchTip (hosted or self-hosted)&lt;/th&gt;
&lt;th&gt;Self-hosted Sentry&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SDK&lt;/td&gt;
&lt;td&gt;Native Sentry SDKs&lt;/td&gt;
&lt;td&gt;Sentry-compatible SDKs, change the DSN&lt;/td&gt;
&lt;td&gt;Native Sentry SDKs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;Errors, performance, session replay, profiling, more&lt;/td&gt;
&lt;td&gt;Mostly error tracking, plus basic uptime and performance features&lt;/td&gt;
&lt;td&gt;Broad, close to hosted feature set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ops burden&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Low: a small app plus Postgres and a queue&lt;/td&gt;
&lt;td&gt;High: many services to run and upgrade&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost driver&lt;/td&gt;
&lt;td&gt;Event volume and add-ons&lt;/td&gt;
&lt;td&gt;Hosted: plan tier; self-hosted: your server&lt;/td&gt;
&lt;td&gt;Server size and your time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main drawback&lt;/td&gt;
&lt;td&gt;Quota anxiety, add-ons priced separately&lt;/td&gt;
&lt;td&gt;Smaller feature set, smaller ecosystem&lt;/td&gt;
&lt;td&gt;Upgrades and resource needs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GlitchTip is open source, and because it speaks the Sentry ingestion protocol you can point an existing SDK at it by swapping the DSN. In my testing of that swap on a small Node service, errors, stack traces and breadcrumbs arrived without code changes. What you give up is breadth: if you rely on session replay, deep performance tracing or the newer AI-assisted triage features, expect gaps, and verify against GlitchTip's current docs before committing.&lt;/p&gt;

&lt;p&gt;Self-hosted Sentry is the full product, but it runs as a large set of containers (databases, queues, ingestion workers and more) and its own docs describe a substantial minimum resource footprint. Upgrades are guided by an install script and are not a casual weekend task. For a team of two to five people, I have seen the hidden cost land as "nobody wants to own the upgrade", which is how an error tracker quietly stops working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the SDK is portable across all three, so the real choice is how much of the platform you want to operate.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is hosted Sentry worth paying for?
&lt;/h2&gt;

&lt;p&gt;If you want one place for errors, performance traces and release health with no infrastructure, hosted Sentry is the product that covers all of that out of the box, and a paid plan is justified once errors are tied to revenue and someone is on call. The drawbacks are real: pricing is usage-based, so a bad incident can push you into overage or throttling, and optional features are billed separately.&lt;/p&gt;

&lt;p&gt;If you only need "tell me when production throws and show me the stack trace", GlitchTip is the one that does that with the least overhead, and it is cheap or free to run on a server you already have. Its limitation is that anything beyond core error tracking is thinner than the original.&lt;/p&gt;

&lt;p&gt;Self-hosted Sentry makes sense mainly when data residency or policy forbids a third-party SaaS and you have someone who will own upgrades.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: pay for hosted Sentry when breadth matters, pick GlitchTip when errors are all you need, and self-host full Sentry only under a compliance constraint.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Is GlitchTip compatible with the Sentry SDK?&lt;/strong&gt;&lt;br&gt;
Yes, GlitchTip accepts events from Sentry client SDKs, so you usually only change the DSN. Features beyond error ingestion, like replay, may not be available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I stop one error from using my whole Sentry quota?&lt;/strong&gt;&lt;br&gt;
Drop repeats in the SDK with &lt;code&gt;beforeSend&lt;/code&gt; and &lt;code&gt;ignoreErrors&lt;/code&gt;, and use &lt;code&gt;sampleRate&lt;/code&gt; for high-volume noise. Server-side spike protection helps, but client-side filtering keeps events from being counted at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is self-hosting Sentry cheaper than the hosted plan?&lt;/strong&gt;&lt;br&gt;
Only if your time is free. The server cost is modest compared with the upgrade and maintenance effort of running a multi-service stack.&lt;/p&gt;

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

&lt;p&gt;Start with hosted Sentry and a &lt;code&gt;beforeSend&lt;/code&gt; limiter; most teams never need to leave. Move to GlitchTip if your bill is driven by volume and you only use error tracking. Self-host full Sentry only when policy forces it and an owner is named. Whatever you choose, test an incident: throw the same error 10,000 times in staging and see what the dashboard and your quota do.&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-grade-search-relevance-before-you-touch-the-ranking-weights-3554"&gt;How to Grade Search Relevance Before You Touch the Ranking Weights&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/your-metrics-bill-is-a-cardinality-bill-how-to-price-prometheus-grafana-cloud-and-datadog-5852"&gt;Your Metrics Bill Is a Cardinality Bill: How to Price Prometheus, Grafana Cloud, and Datadog&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>saas</category>
      <category>opensource</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Docker "toomanyrequests: You have reached your pull rate limit" in CI: Log In, Mirror, or Run a Pull-Through Cache?</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Tue, 20 Oct 2026 21:10:08 +0000</pubDate>
      <link>https://dev.to/libme/docker-toomanyrequests-you-have-reached-your-pull-rate-limit-in-ci-log-in-mirror-or-run-a-4h3e</link>
      <guid>https://dev.to/libme/docker-toomanyrequests-you-have-reached-your-pull-rate-limit-in-ci-log-in-mirror-or-run-a-4h3e</guid>
      <description>&lt;p&gt;If your CI pipeline suddenly fails with &lt;code&gt;toomanyrequests&lt;/code&gt; on &lt;code&gt;docker pull&lt;/code&gt; or &lt;code&gt;FROM&lt;/code&gt;, Docker Hub is throttling the IP your runners share, not your code. The fastest fix is to authenticate the pull with &lt;code&gt;docker login&lt;/code&gt;; the durable fix is to stop pulling the same base images from Docker Hub on every run, using a registry mirror or a pull-through cache. Which one you need depends on how many pulls you make and who owns the IP.&lt;/p&gt;

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

&lt;p&gt;The text varies slightly by Docker version, but the shape is stable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: failed to solve: ubuntu:24.04: failed to resolve source metadata for
docker.io/library/ubuntu:24.04: unexpected status from HEAD request to
https://registry-1.docker.io/v2/library/ubuntu/manifests/24.04: 429 Too Many Requests

toomanyrequests: You have reached your pull rate limit. You may increase the limit
by authenticating and upgrading: https://www.docker.com/increase-rate-limit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What tripped me up the first time: the job had passed for weeks and nothing in the repo changed. The limit is counted per source, not per project. Unauthenticated pulls are counted against the client IP, and hosted CI runners share IP ranges with many other tenants. Your pipeline can fail because a stranger's pipeline pulled too much. Once you authenticate, the count moves to your account.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Takeaway: a rate-limit failure that appears with no code change usually means the shared IP's quota ran out, not that your pipeline got heavier.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I check how many pulls I have left?
&lt;/h2&gt;

&lt;p&gt;Docker documents a special repository, &lt;code&gt;ratelimitpreview/test&lt;/code&gt;, that returns your remaining quota in response headers without spending a pull. Run this from the machine (or CI step) that is failing:&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;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://auth.docker.io/token?service=registry.docker.io&amp;amp;scope=repository:ratelimitpreview/test:pull"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; .token&lt;span class="si"&gt;)&lt;/span&gt;

curl &lt;span class="nt"&gt;-sI&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; ratelimit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will see &lt;code&gt;ratelimit-limit&lt;/code&gt; and &lt;code&gt;ratelimit-remaining&lt;/code&gt;, each with a window (for example &lt;code&gt;;w=21600&lt;/code&gt; for six hours). To see the authenticated numbers, pass your credentials to the token request with &lt;code&gt;curl -u "$USER:$PAT"&lt;/code&gt;. Comparing the two outputs is the quickest way to confirm that logging in actually changes your bucket.&lt;/p&gt;

&lt;p&gt;The exact limits and windows have changed over the years and Docker has revised the free-tier numbers more than once, so read the current figures on Docker's pull-usage documentation rather than trusting any blog post, including this one (as of October 2026, I would not hardcode a number anywhere).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Takeaway: read &lt;code&gt;ratelimit-remaining&lt;/code&gt; before changing anything, so you know whether you are fixing the right problem.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: authenticate every pull (five minutes)
&lt;/h2&gt;

&lt;p&gt;In GitHub Actions, log in before anything touches the registry. Use a personal access token with read-only scope, not your account password:&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;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&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;docker/login-action@v3&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;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKERHUB_USERNAME }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKERHUB_TOKEN }}&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;docker/setup-buildx-action@v3&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;docker/build-push-action@v6&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;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gotchas. First, &lt;code&gt;docker/login-action&lt;/code&gt; makes the login visible to Buildx builds, but a plain &lt;code&gt;docker pull&lt;/code&gt; inside a nested container or a &lt;code&gt;services:&lt;/code&gt; image may use a different credential path. Second, secrets are empty in pull requests from forks (I wrote about that separately), so fork PRs fall back to anonymous pulls and can still hit the limit.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Takeaway: logging in is the cheapest fix, but it only helps jobs that can see the secret.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: stop pulling from Docker Hub at all
&lt;/h2&gt;

&lt;p&gt;Most of the pulls in a typical pipeline are a handful of official base images: &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;python&lt;/code&gt;, &lt;code&gt;postgres&lt;/code&gt;, &lt;code&gt;redis&lt;/code&gt;. Two options remove them from Docker Hub's accounting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a mirror of the official images.&lt;/strong&gt; Amazon publishes copies of Docker's official images on its public registry, so &lt;code&gt;FROM public.ecr.aws/docker/library/node:22-slim&lt;/code&gt; pulls the same library image from a different provider. The limitation is that only the official library images are mirrored there, not arbitrary third-party images, and you depend on that copy staying in sync (check the tag you pin actually exists before you rewrite every Dockerfile).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run a pull-through cache.&lt;/strong&gt; A cache sits between your runners and Docker Hub, stores each layer once, and serves it to every later job. The open source &lt;code&gt;registry&lt;/code&gt; image can do this in a few lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config.yml for the registry:2 image&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.1&lt;/span&gt;
&lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;filesystem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;rootdirectory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/lib/registry&lt;/span&gt;
&lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;addr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;:5000&lt;/span&gt;
&lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;remoteurl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://registry-1.docker.io&lt;/span&gt;
  &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-dockerhub-user&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-dockerhub-read-token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then point the Docker daemon at it with &lt;code&gt;"registry-mirrors": ["http://your-cache:5000"]&lt;/code&gt; in &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt;. Note that this mirror setting only applies to Docker Hub images, which is exactly the case you are fixing. Cloud registries offer managed versions of the same idea (Amazon ECR's pull-through cache can front Docker Hub when you give it credentials), which removes the server you would otherwise babysit.&lt;/p&gt;

&lt;p&gt;The drawbacks are real. A self-run cache is another service that can go down and block every build, it needs disk cleanup, and on ephemeral hosted runners there is no private network to put it on. If your builds run on self-hosted runners in one network, a cache pays off quickly; on hosted runners, prefer the mirror or login.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Takeaway: a cache turns N pulls per day into one, but only if your runners can actually reach it.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which option fits which setup?
&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;Best fix&lt;/th&gt;
&lt;th&gt;Main drawback&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hosted CI, few images, secrets available&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;docker login&lt;/code&gt; with a read-only token&lt;/td&gt;
&lt;td&gt;Fork PRs still anonymous&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosted CI, mostly official base images&lt;/td&gt;
&lt;td&gt;Mirror via &lt;code&gt;public.ecr.aws/docker/library&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Official images only; tag sync&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted runners, many pipelines&lt;/td&gt;
&lt;td&gt;Pull-through cache&lt;/td&gt;
&lt;td&gt;You run and patch another service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Already on AWS with ECR&lt;/td&gt;
&lt;td&gt;ECR pull-through cache&lt;/td&gt;
&lt;td&gt;Setup of credentials and repo prefixes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publishing your own images too&lt;/td&gt;
&lt;td&gt;Push to GHCR or ECR, keep Hub as a pull source&lt;/td&gt;
&lt;td&gt;Migration of image names&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you are choosing a place for your own images, GitHub's container registry is the lowest-friction choice when your code already lives on GitHub, since &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; can authenticate the push without another secret.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Takeaway: pick by where your runners live, not by which fix sounds most robust.&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why does docker pull say toomanyrequests when I only pulled once?&lt;/strong&gt;&lt;br&gt;
Unauthenticated pulls are counted per IP address, so shared CI or office NAT addresses burn the quota across many users. Authenticating moves the count to your account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do HEAD requests count toward the Docker Hub rate limit?&lt;/strong&gt;&lt;br&gt;
Docker's documentation says manifest HEAD requests do not count as pulls, which is why the &lt;code&gt;ratelimitpreview&lt;/code&gt; check above is free. The &lt;code&gt;429&lt;/code&gt; in a build log can still appear on a HEAD when you are already over the limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I avoid Docker Hub rate limits without paying?&lt;/strong&gt;&lt;br&gt;
Authenticate with a free-account token, pin base images to a mirror such as the Amazon ECR public registry copy of the official images, and cache layers in CI so unchanged layers are not re-pulled.&lt;/p&gt;

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

&lt;p&gt;If you are on hosted CI with a handful of images, add &lt;code&gt;docker/login-action&lt;/code&gt; with a read-only token today and check &lt;code&gt;ratelimit-remaining&lt;/code&gt; to confirm it worked. If most of your pulls are official images, move the &lt;code&gt;FROM&lt;/code&gt; lines to a mirror and you are mostly done. Only build a pull-through cache when you run self-hosted runners at a scale where a mirror's gaps start to hurt, and plan for it being one more thing that can break your builds.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/build-vs-buy-authentication-in-2026-auth0-clerk-supabase-auth-or-roll-your-own-3mc8"&gt;Build vs Buy: Authentication in 2026 (Auth0, Clerk, Supabase Auth, or Roll Your Own)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/curl-returns-200-but-your-http-client-gets-a-404-debugging-vary-and-cdn-cache-variants-38je"&gt;curl Returns 200 but Your HTTP Client Gets a 404: Debugging Vary and CDN Cache Variants&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/caddy-vs-nginx-vs-traefik-which-reverse-proxy-for-one-small-app-on-one-server-cc2"&gt;Caddy vs Nginx vs Traefik: Which Reverse Proxy for One Small App on One Server?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>cicd</category>
      <category>devops</category>
      <category>tooling</category>
    </item>
    <item>
      <title>ERR_REQUIRE_ESM in Node: Fix CommonJS/ESM Conflicts and Pick tsx, ts-node or tsup</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Mon, 19 Oct 2026 22:04:47 +0000</pubDate>
      <link>https://dev.to/libme/errrequireesm-in-node-fix-commonjsesm-conflicts-and-pick-tsx-ts-node-or-tsup-16ih</link>
      <guid>https://dev.to/libme/errrequireesm-in-node-fix-commonjsesm-conflicts-and-pick-tsx-ts-node-or-tsup-16ih</guid>
      <description>&lt;p&gt;If you see &lt;code&gt;Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported&lt;/code&gt;, a CommonJS file is trying to &lt;code&gt;require()&lt;/code&gt; a package that now ships only ES modules. As of late 2026 the quickest fix is often upgrading Node (20.19+ or 22.12+ can &lt;code&gt;require()&lt;/code&gt; ES modules), but the durable fix is making your project's module format consistent: either go all-ESM, or pin the dependency and bundle.&lt;/p&gt;

&lt;p&gt;This post covers the error variants you will actually hit, why each happens, and which TypeScript runner (tsx, ts-node, tsup) to use so you stop fighting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does ERR_REQUIRE_ESM actually mean?
&lt;/h2&gt;

&lt;p&gt;Node has two module systems. CommonJS uses &lt;code&gt;require()&lt;/code&gt; and &lt;code&gt;module.exports&lt;/code&gt;. ES modules use &lt;code&gt;import&lt;/code&gt;/&lt;code&gt;export&lt;/code&gt;. For years, CommonJS could not load ESM synchronously, because ESM loading is asynchronous by design. The classic failure looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error [ERR_REQUIRE_ESM]: require() of ES Module /app/node_modules/chalk/source/index.js
from /app/dist/index.js not supported.
Instead change the require of index.js in /app/dist/index.js to a dynamic import() which is available in all CommonJS modules.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This usually appears after a routine &lt;code&gt;npm update&lt;/code&gt;. A maintainer of a dependency (chalk, node-fetch and nanoid are well-known past examples) released a major version that dropped CommonJS, and your lockfile picked it up. Nothing in your code changed, which is why it feels random.&lt;/p&gt;

&lt;p&gt;The trap: TypeScript compiled with &lt;code&gt;"module": "commonjs"&lt;/code&gt; rewrites your &lt;code&gt;import chalk from 'chalk'&lt;/code&gt; into a &lt;code&gt;require('chalk')&lt;/code&gt;. Your source looks like ESM, your output is CommonJS, and the crash happens at runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the error is about the format of the file doing the requiring, not the syntax in your source.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Does newer Node make this go away?
&lt;/h2&gt;

&lt;p&gt;Partly. As of late 2026, &lt;code&gt;require()&lt;/code&gt; of a synchronous ES module works without flags on Node 22.12+ and 20.19+ (earlier versions needed &lt;code&gt;--experimental-require-module&lt;/code&gt;). Check with &lt;code&gt;node -v&lt;/code&gt; before debugging anything else; in my experience a stale Node in a Docker base image is a common reason the error persists on a server while it works on a laptop.&lt;/p&gt;

&lt;p&gt;It has real limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A module that uses top-level &lt;code&gt;await&lt;/code&gt; still fails, with &lt;code&gt;ERR_REQUIRE_ASYNC_MODULE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;require()&lt;/code&gt; returns the module namespace object, so a default export arrives as &lt;code&gt;.default&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// CommonJS file requiring an ESM-only package on a supporting Node version&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mod&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;some-esm-only-package&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;fn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;mod&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// default export lives on .default&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Bundlers and some older tooling (Jest in its default CommonJS mode, for example) have their own resolution and may still choke.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: upgrading Node fixes the common case, but top-level await and default-export shape are the two places it still bites.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you fix it permanently?
&lt;/h2&gt;

&lt;p&gt;You have three honest options, in order of how much I recommend them for new code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Go all-ESM.&lt;/strong&gt; Set &lt;code&gt;"type": "module"&lt;/code&gt; in &lt;code&gt;package.json&lt;/code&gt;, and in &lt;code&gt;tsconfig.json&lt;/code&gt; use a Node-aware setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NodeNext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"moduleResolution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NodeNext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ES2022"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"outDir"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dist"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"strict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Relative imports then need explicit extensions, and this is the part that trips people up. Write &lt;code&gt;import { db } from './db.js'&lt;/code&gt; even though the file on disk is &lt;code&gt;db.ts&lt;/code&gt;. TypeScript maps it, and Node needs the &lt;code&gt;.js&lt;/code&gt; at runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Stay CommonJS and use dynamic import for the one offender.&lt;/strong&gt; This works in any CommonJS file on any supported Node:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;main&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="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chalk&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="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chalk&lt;/span&gt;&lt;span class="dl"&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chalk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;green&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="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;One catch: if TypeScript is compiling to CommonJS, it will rewrite &lt;code&gt;import()&lt;/code&gt; into &lt;code&gt;require()&lt;/code&gt; and bring the error back. With &lt;code&gt;module: "NodeNext"&lt;/code&gt; in a CommonJS package it preserves dynamic &lt;code&gt;import()&lt;/code&gt;; with the older &lt;code&gt;module: "commonjs"&lt;/code&gt; it does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Pin the last CommonJS major.&lt;/strong&gt; Fast, and a legitimate stopgap, but you stop receiving security fixes for that dependency. Treat it as a ticket, not a solution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: pick one module format per package and make TypeScript's &lt;code&gt;module&lt;/code&gt; setting match it; most of these errors are a mismatch between the two.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  "Unknown file extension .ts": which TypeScript runner should you use?
&lt;/h2&gt;

&lt;p&gt;The second error people search for right after the first is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts" for /app/src/index.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It means Node's ESM loader was handed a &lt;code&gt;.ts&lt;/code&gt; file with no loader that understands TypeScript. This is the classic symptom of running &lt;code&gt;ts-node&lt;/code&gt; against an ESM project without the right loader flags. Your options:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Type checks?&lt;/th&gt;
&lt;th&gt;Best for&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;tsx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs TS/ESM/CJS files directly, using esbuild under the hood&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Scripts, dev servers, mixed CJS/ESM repos&lt;/td&gt;
&lt;td&gt;Won't catch type errors; run &lt;code&gt;tsc --noEmit&lt;/code&gt; separately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ts-node&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Compiles with the TypeScript compiler at runtime&lt;/td&gt;
&lt;td&gt;Yes (unless &lt;code&gt;--transpile-only&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Existing projects already configured for it&lt;/td&gt;
&lt;td&gt;ESM support needs careful config and tends to produce exactly the error above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tsup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bundles TS into &lt;code&gt;dist/&lt;/code&gt; (CJS, ESM or both) with &lt;code&gt;.d.ts&lt;/code&gt; output&lt;/td&gt;
&lt;td&gt;Declarations only, via a flag&lt;/td&gt;
&lt;td&gt;Publishing libraries, shipping a single built artifact&lt;/td&gt;
&lt;td&gt;A build step; not a runtime launcher&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node native type stripping&lt;/td&gt;
&lt;td&gt;Newer Node versions can run &lt;code&gt;.ts&lt;/code&gt; files directly&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Simple scripts with no enums or path aliases&lt;/td&gt;
&lt;td&gt;Supports only erasable TypeScript syntax; check your Node version&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A working dev setup I would start from today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsx watch src/index.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"typecheck"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsc --noEmit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tsup src/index.ts --format esm --target node20 --clean"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node dist/index.js"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want a runner that stops you thinking about CommonJS versus ESM during development, &lt;code&gt;tsx&lt;/code&gt; is the one that handles both without configuration. It will not tell you about type errors, so the &lt;code&gt;typecheck&lt;/code&gt; script in CI is not optional.&lt;/p&gt;

&lt;p&gt;If you are publishing a library that consumers on both module systems will import, &lt;code&gt;tsup&lt;/code&gt; is the tool that emits both formats plus type declarations from one command. You still need to get the &lt;code&gt;exports&lt;/code&gt; map in &lt;code&gt;package.json&lt;/code&gt; right, and tsup does not do that for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: use tsx to run, &lt;code&gt;tsc --noEmit&lt;/code&gt; to check, and tsup to ship; ts-node is only worth keeping if it already works in your repo.&lt;/strong&gt;&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why does ERR_REQUIRE_ESM only happen after npm update?
&lt;/h3&gt;

&lt;p&gt;A dependency released a major version that is ESM-only, and your version range allowed it. Check the package's changelog and &lt;code&gt;"type"&lt;/code&gt;/&lt;code&gt;"exports"&lt;/code&gt; fields in its &lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use require() on an ES module?
&lt;/h3&gt;

&lt;p&gt;Yes on Node 22.12+ and 20.19+, as long as the module has no top-level await. Older versions throw ERR_REQUIRE_ESM, and you need dynamic &lt;code&gt;import()&lt;/code&gt; instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do I need .js extensions in TypeScript imports?
&lt;/h3&gt;

&lt;p&gt;Node's ESM resolver does not guess extensions, and TypeScript does not rewrite your import paths. You write the extension the file will have after compilation, which is &lt;code&gt;.js&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;For a new project, go all-ESM with &lt;code&gt;"type": "module"&lt;/code&gt;, &lt;code&gt;NodeNext&lt;/code&gt; module settings, tsx during development, and tsup if you publish. For an existing CommonJS codebase hit by one ESM-only dependency, upgrade Node first, then use dynamic &lt;code&gt;import()&lt;/code&gt; for that package, and pin the old major only as a short-lived stopgap. Whichever way you go, keep &lt;code&gt;tsc --noEmit&lt;/code&gt; in CI, because the fast runners all skip type checking.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/exit-code-137-why-your-container-gets-oomkilled-while-nodes-heap-looks-fine-412p"&gt;Exit Code 137: Why Your Container Gets OOMKilled While Node's Heap Looks Fine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/cutting-your-side-projects-cloud-bill-a-checklist-that-doesnt-sacrifice-uptime-17kf"&gt;Cutting Your Side Project's Cloud Bill: A Checklist That Doesn't Sacrifice Uptime&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/postgres-could-not-resize-shared-memory-segment-no-space-left-on-device-in-docker-why-it-54cg"&gt;Postgres "could not resize shared memory segment: No space left on device" in Docker: Why It Happens and How to Fix It&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>typescript</category>
      <category>javascript</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Node.js "fetch failed" with ETIMEDOUT or ENETUNREACH While curl Works: The Broken IPv6 Route</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sun, 18 Oct 2026 22:11:28 +0000</pubDate>
      <link>https://dev.to/libme/nodejs-fetch-failed-with-etimedout-or-enetunreach-while-curl-works-the-broken-ipv6-route-7g7</link>
      <guid>https://dev.to/libme/nodejs-fetch-failed-with-etimedout-or-enetunreach-while-curl-works-the-broken-ipv6-route-7g7</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; if a Node.js request dies with &lt;code&gt;TypeError: fetch failed&lt;/code&gt;, &lt;code&gt;AggregateError [ETIMEDOUT]&lt;/code&gt;, or &lt;code&gt;connect ENETUNREACH 2606:...&lt;/code&gt; but &lt;code&gt;curl&lt;/code&gt; to the same host succeeds, the hostname probably resolves to both IPv6 and IPv4 addresses, and your machine or container has an IPv6 address family it cannot actually route. Confirm it with &lt;code&gt;curl -4&lt;/code&gt; versus &lt;code&gt;curl -6&lt;/code&gt;, then either fix the network or tell Node to prefer IPv4 and fall back faster.&lt;/p&gt;

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

&lt;p&gt;The surface message from the built-in &lt;code&gt;fetch&lt;/code&gt; is unhelpful:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: fetch failed
    at node:internal/deps/undici/undici:...
  [cause]: AggregateError [ETIMEDOUT]:
      at internalConnectMultiple (node:net:...)
    [errors]: [
      Error: connect ETIMEDOUT 2606:4700:...:443
      Error: connect ENETUNREACH 2606:4700:...:443
      Error: connect ETIMEDOUT 104.18.x.x:443
    ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detail is in &lt;code&gt;error.cause&lt;/code&gt;. Log it, or you will chase the wrong problem. The giveaway is the mix of IPv6 and IPv4 addresses in one &lt;code&gt;AggregateError&lt;/code&gt;: Node tried several addresses for the same hostname and none connected in time.&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com/health&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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cause&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cause&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway: always print &lt;code&gt;err.cause&lt;/code&gt;; &lt;code&gt;fetch failed&lt;/code&gt; is a wrapper, and the per-address errors are the diagnosis.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does curl work when Node does not?
&lt;/h2&gt;

&lt;p&gt;Two behaviors changed across recent Node versions, and they interact badly with half-configured networks. As of Node 17, &lt;code&gt;dns.lookup&lt;/code&gt; returns addresses in the order the resolver gives them (&lt;code&gt;verbatim&lt;/code&gt;), so IPv6 addresses typically come first instead of being sorted behind IPv4. As of Node 20, &lt;code&gt;net.connect&lt;/code&gt; tries multiple addresses using "happy eyeballs" (&lt;code&gt;autoSelectFamily&lt;/code&gt;), but each attempt has a short default timeout (documented as 250 ms), which is tight on slow or cross-region links.&lt;/p&gt;

&lt;p&gt;curl, by contrast, has had happy-eyeballs logic for years and tends to fall back to IPv4 quickly. So the same host can feel fine from curl and flaky from Node, especially on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker containers or CI runners where the host has no IPv6 route but DNS still returns AAAA records.&lt;/li&gt;
&lt;li&gt;VPNs and corporate networks that advertise IPv6 but drop the packets.&lt;/li&gt;
&lt;li&gt;Home or office routers with a half-working IPv6 prefix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What tripped me up: the failure is often intermittent, because it depends on which address wins the race and how loaded the link is. It looks like a flaky upstream when it is really your network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: AAAA records plus no working IPv6 route equals a slow or failed first attempt, and Node's default fallback window is short.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I confirm it is IPv6?
&lt;/h2&gt;

&lt;p&gt;Force each address family from the same machine (or inside the same container) that fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-4&lt;/span&gt; &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"ipv4: %{http_code} %{time_total}s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://api.example.com/health
curl &lt;span class="nt"&gt;-6&lt;/span&gt; &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"ipv6: %{http_code} %{time_total}s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://api.example.com/health
getent ahosts api.example.com   &lt;span class="c"&gt;# which addresses, in which order&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;-4&lt;/code&gt; succeeds quickly and &lt;code&gt;-6&lt;/code&gt; hangs or prints "Network is unreachable", you have your answer. Run it inside the container, not on your laptop; the host and the container often have different routes.&lt;/p&gt;

&lt;p&gt;You can also reproduce it in Node without any HTTP library:&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;dns&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:dns/promises&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;net&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:net&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;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;api.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;family&lt;/span&gt; &lt;span class="k"&gt;of&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="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;address&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;dns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;family&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;net&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="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timeout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timeout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`IPv&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;family&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; OK (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;address&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="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`IPv&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;family&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; FAILED: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway: test each address family separately, from inside the environment that fails.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which fix should I use?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fix the network (enable IPv6 on the Docker network, or stop advertising AAAA)&lt;/td&gt;
&lt;td&gt;Whole machine&lt;/td&gt;
&lt;td&gt;Correct but needs infra access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NODE_OPTIONS=--dns-result-order=ipv4first&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whole process, no code change&lt;/td&gt;
&lt;td&gt;Applies to all lookups, including hosts where IPv6 is the good path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;dns.setDefaultResultOrder("ipv4first")&lt;/code&gt; at startup&lt;/td&gt;
&lt;td&gt;Same, in code&lt;/td&gt;
&lt;td&gt;Must run before the first lookup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;net.setDefaultAutoSelectFamilyAttemptTimeout(500)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Gives slow links more time per address&lt;/td&gt;
&lt;td&gt;Does not help if IPv6 is truly dead; only reduces false fallbacks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disable IPv6 via sysctl in the container&lt;/td&gt;
&lt;td&gt;Container&lt;/td&gt;
&lt;td&gt;Blunt; breaks hosts that are IPv6-only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The least invasive one I reach for first:&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;// top of your entrypoint, before any network call&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;dns&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:dns&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;net&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:net&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDefaultResultOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ipv4first&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDefaultAutoSelectFamilyAttemptTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ms per address; Node 20+&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or, with no code change, in your Dockerfile or deployment config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_OPTIONS="--dns-result-order=ipv4first"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Caveats worth knowing. These settings affect &lt;code&gt;dns.lookup&lt;/code&gt;-based connections, which is what &lt;code&gt;net&lt;/code&gt;, &lt;code&gt;http&lt;/code&gt;, and &lt;code&gt;fetch&lt;/code&gt; use by default; libraries that do their own resolution may behave differently. Pinning IPv4 first is a workaround, not a diagnosis: if your platform later becomes IPv6-only on the edge, you will want the real fix. And on Node versions before 20, &lt;code&gt;autoSelectFamily&lt;/code&gt; is opt-in, so the attempt-timeout call does nothing useful there.&lt;/p&gt;

&lt;p&gt;If you run on Docker and want the proper fix, either enable IPv6 on the daemon's network with a valid prefix, or accept IPv4-only and make sure your resolver stops handing back AAAA records the container can't use. As of late 2026 I still see Docker's default bridge network without IPv6 on most hosts, so this class of bug keeps showing up in containers first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: prefer &lt;code&gt;ipv4first&lt;/code&gt; as a fast, reversible mitigation, and treat the network fix as the real one.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Is this a reason to pay for anything?
&lt;/h2&gt;

&lt;p&gt;Not really, and that is the point. This is a configuration problem, not a tooling gap. The one case where money enters the picture is when you do not control the network: if a locked-down CI runner or serverless platform has flaky IPv6 and you cannot change it, the cheap fix is the environment variable above rather than migrating providers. I would only move platforms if the vendor cannot fix routing on their side and it blocks you repeatedly.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why does Node say "fetch failed" instead of the real error?&lt;/strong&gt;&lt;br&gt;
Node's built-in &lt;code&gt;fetch&lt;/code&gt; (undici) wraps the underlying socket error. The real reason is in &lt;code&gt;error.cause&lt;/code&gt;, and for multi-address failures in &lt;code&gt;error.cause.errors&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does curl work but Node fetch times out?&lt;/strong&gt;&lt;br&gt;
curl falls back across IPv4 and IPv6 quickly, while Node's per-address connect attempt has a short default timeout. If IPv6 is unroutable, Node can exhaust its attempts before reaching a working IPv4 address.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I make Node prefer IPv4?&lt;/strong&gt;&lt;br&gt;
Set &lt;code&gt;NODE_OPTIONS=--dns-result-order=ipv4first&lt;/code&gt;, or call &lt;code&gt;dns.setDefaultResultOrder("ipv4first")&lt;/code&gt; before any network request.&lt;/p&gt;

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

&lt;p&gt;If you see &lt;code&gt;fetch failed&lt;/code&gt; with a mix of IPv6 and IPv4 addresses in the cause, test &lt;code&gt;curl -4&lt;/code&gt; against &lt;code&gt;curl -6&lt;/code&gt; from inside the failing environment before touching application code. For a quick, reversible mitigation, set &lt;code&gt;--dns-result-order=ipv4first&lt;/code&gt; and raise the attempt timeout; for a lasting one, fix IPv6 routing or stop publishing AAAA records your network cannot use. Do not add retries around it: retries on a dead address family just make the same slow failure repeat.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/intermittent-502s-and-read-econnreset-behind-a-load-balancer-the-keep-alive-timeout-mismatch-1b3n"&gt;Intermittent 502s and "read ECONNRESET" Behind a Load Balancer: The Keep-Alive Timeout Mismatch&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;li&gt;&lt;a href="https://dev.to/libme/why-your-postgres-migration-locked-the-whole-table-and-the-pattern-that-doesnt-38k4"&gt;Why Your Postgres Migration Locked the Whole Table (and the Pattern That Doesn't)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>devops</category>
      <category>docker</category>
      <category>backend</category>
    </item>
    <item>
      <title>ngrok vs Cloudflare Tunnel vs Tailscale Funnel: Which One for Testing Webhooks on Localhost?</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sat, 17 Oct 2026 22:59:38 +0000</pubDate>
      <link>https://dev.to/libme/ngrok-vs-cloudflare-tunnel-vs-tailscale-funnel-which-one-for-testing-webhooks-on-localhost-3l23</link>
      <guid>https://dev.to/libme/ngrok-vs-cloudflare-tunnel-vs-tailscale-funnel-which-one-for-testing-webhooks-on-localhost-3l23</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; For a throwaway webhook test, Cloudflare's quick tunnel is the lowest-friction option because it needs no account. For a URL you register once with Stripe, GitHub, or Slack and keep for months, you want a stable hostname: ngrok with a reserved domain, a named Cloudflare Tunnel, or Tailscale Funnel if your machine is already on a tailnet. The deciding factors are URL stability, what the tunnel does to your requests, and whether you already own a domain.&lt;/p&gt;

&lt;p&gt;All three behaviors below are as of October 2026. Tunnel vendors change free-tier terms often, so check the current docs before you commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What problem are you actually solving?
&lt;/h2&gt;

&lt;p&gt;A webhook provider needs a public HTTPS URL. Your handler runs on &lt;code&gt;localhost:3000&lt;/code&gt;. A tunnel gives you a public hostname that forwards to that port.&lt;/p&gt;

&lt;p&gt;Before comparing tools, start with a handler that shows you what really arrives. Most "the tunnel is broken" reports I have debugged turned out to be the handler, not the tunnel.&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;// server.js: run with: node server.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;http&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:http&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;end&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; bytes`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;headers:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/plain&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;listening on :3000&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;Once this prints a request through your tunnel, you know the path works end to end. Then swap in your real handler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: prove the tunnel with a dumb echo server first, so you never debug your handler and your tunnel at the same time.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do the three compare?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;ngrok&lt;/th&gt;
&lt;th&gt;Cloudflare Tunnel&lt;/th&gt;
&lt;th&gt;Tailscale Funnel&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Account needed&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Quick tunnel: no. Named tunnel: yes, plus a domain on Cloudflare&lt;/td&gt;
&lt;td&gt;Yes, plus a tailnet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throwaway URL&lt;/td&gt;
&lt;td&gt;Random, changes per session unless you reserve a domain&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;trycloudflare.com&lt;/code&gt; random URL, changes every run&lt;/td&gt;
&lt;td&gt;Stable per machine name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stable URL, free&lt;/td&gt;
&lt;td&gt;Typically one static domain on the free plan (verify current terms)&lt;/td&gt;
&lt;td&gt;Yes, with a named tunnel on your own domain&lt;/td&gt;
&lt;td&gt;Yes, &lt;code&gt;&amp;lt;machine&amp;gt;.&amp;lt;tailnet&amp;gt;.ts.net&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request inspector / replay&lt;/td&gt;
&lt;td&gt;Built in, the main reason people pick it&lt;/td&gt;
&lt;td&gt;None built in&lt;/td&gt;
&lt;td&gt;None built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ports&lt;/td&gt;
&lt;td&gt;Any local port&lt;/td&gt;
&lt;td&gt;Any local port&lt;/td&gt;
&lt;td&gt;Public side limited to a few ports (443, 8443, 10000)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main gotcha&lt;/td&gt;
&lt;td&gt;Free-tier browser interstitial page&lt;/td&gt;
&lt;td&gt;Quick tunnels are for testing, with documented limits&lt;/td&gt;
&lt;td&gt;Needs HTTPS and MagicDNS enabled on the tailnet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When is ngrok the right pick?
&lt;/h2&gt;

&lt;p&gt;ngrok's real advantage is the request inspector. You open its local web UI, see every incoming webhook with headers and body, and replay one with a click. When a provider sends a payload once and your handler crashed on it, replay saves you from re-triggering the event. For debugging signature verification, that matters; if you are fighting a failing signature check, being able to replay the exact bytes is worth a lot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ngrok http 3000
&lt;span class="c"&gt;# with a reserved domain from your dashboard:&lt;/span&gt;
ngrok http &lt;span class="nt"&gt;--url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-name.ngrok-free.app 3000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flag name for the domain has changed between ngrok CLI versions, so run &lt;code&gt;ngrok http --help&lt;/code&gt; rather than trusting a blog post, including this one.&lt;/p&gt;

&lt;p&gt;Limitations I have hit: the free plan can show an interstitial warning page to browser traffic. Webhook providers are not browsers, so they are usually unaffected, but if you test with a browser or a tool that sends a browser-like user agent, you may see the interstitial instead of your app. Also, your traffic and your inspector history go through a third party, which matters if payloads contain real customer data.&lt;/p&gt;

&lt;p&gt;If you want the managed version of this with request replay built in, ngrok is the one that gives you inspection and a stable domain without owning DNS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: pick ngrok when you need to replay and inspect payloads, and accept that your webhook traffic transits its service.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When does Cloudflare Tunnel win?
&lt;/h2&gt;

&lt;p&gt;Two modes, and people conflate them.&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;# Quick tunnel: no account, random URL, dies when the process stops&lt;/span&gt;
cloudflared tunnel &lt;span class="nt"&gt;--url&lt;/span&gt; http://localhost:3000

&lt;span class="c"&gt;# Named tunnel: stable hostname on a domain you manage in Cloudflare&lt;/span&gt;
cloudflared tunnel login
cloudflared tunnel create dev-webhooks
cloudflared tunnel route dns dev-webhooks hooks.example.com
cloudflared tunnel run &lt;span class="nt"&gt;--url&lt;/span&gt; http://localhost:3000 dev-webhooks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The quick tunnel is the fastest path from zero to a working public URL. Cloudflare documents it as a testing feature with limits, and as of my last check it does not support Server-Sent Events, so if your handler streams, test with a named tunnel instead. The URL changes every run, which means re-registering the webhook each time. Fine for a ten-minute experiment, painful for a week of development.&lt;/p&gt;

&lt;p&gt;The named tunnel gives you a permanent hostname, but only if your domain's DNS is on Cloudflare. If it is not, you are signing up for a DNS migration just to test a webhook. If you already have a domain there, Cloudflare Tunnel is the one that gives you a permanent hostname at no extra cost and with no third-party hostname in your provider's settings.&lt;/p&gt;

&lt;p&gt;Drawback: there is no built-in request inspector. You add logging to your own handler, which is why the echo server above is useful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: Cloudflare Tunnel is the best stable free option only if your domain already lives in Cloudflare; otherwise use the quick tunnel for short tests.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Is Tailscale Funnel a good fit for webhooks?
&lt;/h2&gt;

&lt;p&gt;If your laptop or home server is already on a tailnet, it is nearly free to set up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tailscale funnel 3000
tailscale funnel status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get &lt;code&gt;https://&amp;lt;machine&amp;gt;.&amp;lt;tailnet&amp;gt;.ts.net&lt;/code&gt;, stable across sessions, with a valid certificate. You must have HTTPS certificates and MagicDNS enabled for the tailnet, and Funnel has to be allowed in the access policy; the first run prints what is missing.&lt;/p&gt;

&lt;p&gt;The catch is exposure model. Funnel makes a service on your machine reachable from the public internet, and bandwidth limits exist that you cannot configure. The public ports are restricted to a short list, so you cannot pick an arbitrary one. For a webhook receiver this is acceptable; for anything heavier, it is not the tool.&lt;/p&gt;

&lt;p&gt;Because the hostname contains your tailnet name, you are also publishing that name in whatever dashboard you register it in. Some people do not want that in a shared Stripe account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: Funnel is the right answer when you are already a Tailscale user and want a stable URL without touching DNS.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why does my webhook work with ngrok but fail signature verification?&lt;/strong&gt;&lt;br&gt;
Usually something re-serialized the body before you hashed it. Verify against the raw bytes, not a parsed-and-restringified JSON object; tunnels forward bytes unchanged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use a Cloudflare quick tunnel for a permanent webhook endpoint?&lt;/strong&gt;&lt;br&gt;
No. The URL is random and changes on every run, so the provider's registered URL breaks as soon as you restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need a tunnel if I use the provider's CLI?&lt;/strong&gt;&lt;br&gt;
Often not. Some providers ship a CLI that forwards events to localhost for you, which skips the public URL entirely, but it only covers that one provider.&lt;/p&gt;

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

&lt;p&gt;Use Cloudflare's quick tunnel for a ten-minute smoke test. Use ngrok when you are debugging payloads and want replay. Use a named Cloudflare Tunnel if your domain is already on Cloudflare and you want a permanent URL you control. Use Tailscale Funnel if you already live on a tailnet. Whichever you choose, run the echo server first.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/an-ai-assisted-code-review-pipeline-that-catches-what-humans-skim-past-5hc0"&gt;An AI-Assisted Code Review Pipeline That Catches What Humans Skim Past&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/the-architecture-diagram-in-your-wiki-is-lying-how-to-generate-one-from-the-code-instead-5cc8"&gt;The Architecture Diagram in Your Wiki Is Lying: How to Generate One From the Code Instead&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/automate-your-code-reviews-with-an-llm-without-annoying-your-team-5h2n"&gt;Automate Your Code Reviews with an LLM Without Annoying Your Team&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>devops</category>
      <category>tooling</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Postgres "deadlock detected": Fix the Lock Order First, Then Add a Retry</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Fri, 16 Oct 2026 22:11:13 +0000</pubDate>
      <link>https://dev.to/libme/postgres-deadlock-detected-fix-the-lock-order-first-then-add-a-retry-3ip1</link>
      <guid>https://dev.to/libme/postgres-deadlock-detected-fix-the-lock-order-first-then-add-a-retry-3ip1</guid>
      <description>&lt;p&gt;Postgres raises &lt;code&gt;ERROR: deadlock detected&lt;/code&gt; (SQLSTATE &lt;code&gt;40P01&lt;/code&gt;) when two transactions each hold a lock the other needs. The durable fix is to make every code path lock rows in the same order; a retry on &lt;code&gt;40P01&lt;/code&gt; is the safety net, not the cure. If you only add the retry, you hide the bug and pay for it in latency.&lt;/p&gt;

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

&lt;p&gt;This is the shape of the message you get in the application and the server log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  deadlock detected
DETAIL:  Process 8121 waits for ShareLock on transaction 90417; blocked by process 8130.
Process 8130 waits for ShareLock on transaction 90416; blocked by process 8121.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (0,12) in relation "accounts"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as a cycle. Process 8121 is waiting for a transaction that process 8130 owns, and 8130 is waiting for one that 8121 owns. Postgres notices the cycle after &lt;code&gt;deadlock_timeout&lt;/code&gt; (1 second by default), picks one transaction as the victim, and aborts it. The other one proceeds as if nothing happened.&lt;/p&gt;

&lt;p&gt;Two things trip people up. The victim is not necessarily the "wrong" transaction, so you cannot fix this by blaming one code path. And the &lt;code&gt;CONTEXT&lt;/code&gt; line names only the row the victim was waiting on when it was killed, not the whole story. The server log, not the client error, has the full query text for both sides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a deadlock is a cycle between two or more transactions, and the error message is only the last edge of that cycle.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does it only happen under load?
&lt;/h2&gt;

&lt;p&gt;A deadlock needs two transactions to interleave at exactly the wrong moment, so it passes every test you run alone. The classic version is a transfer between two accounts:&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;-- Transaction A (transfer 1 -&amp;gt; 2)&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&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;span class="c1"&gt;-- Transaction B (transfer 2 -&amp;gt; 1), running at the same moment&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&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;A locks row 1, B locks row 2, then each reaches for the row the other holds. Neither can finish. Nothing about this is a Postgres quirk; any database with row locks behaves this way.&lt;/p&gt;

&lt;p&gt;The less obvious sources, in the order I tend to find them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Batch updates with no stable order.&lt;/strong&gt; &lt;code&gt;UPDATE ... WHERE id IN (...)&lt;/code&gt; locks rows in whatever order the plan visits them. Two workers updating overlapping sets can lock in opposite orders. Postgres does not guarantee the order from the &lt;code&gt;IN&lt;/code&gt; list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foreign keys.&lt;/strong&gt; Inserting a child row takes a &lt;code&gt;FOR KEY SHARE&lt;/code&gt; lock on the parent row. Concurrent inserts of children plus an update of the same parent can form a cycle you never wrote explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upserts and unique indexes.&lt;/strong&gt; Two transactions inserting overlapping sets of keys with &lt;code&gt;INSERT ... ON CONFLICT&lt;/code&gt; in different orders can deadlock on the index entries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixed application paths.&lt;/strong&gt; One endpoint locks &lt;code&gt;orders&lt;/code&gt; then &lt;code&gt;inventory&lt;/code&gt;; a background job locks &lt;code&gt;inventory&lt;/code&gt; then &lt;code&gt;orders&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: most production deadlocks come from two code paths touching the same rows in different orders, not from a single bad query.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you find the two queries involved?
&lt;/h2&gt;

&lt;p&gt;Turn on lock-wait logging so the server log tells you who waited on whom. These are standard settings; set them in &lt;code&gt;postgresql.conf&lt;/code&gt; or through your managed provider's parameter UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;log_lock_waits&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;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_reload_conf&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;log_lock_waits&lt;/code&gt; on, any wait longer than &lt;code&gt;deadlock_timeout&lt;/code&gt; is logged, and a deadlock entry includes the statements for each process involved. Pair that with &lt;code&gt;log_line_prefix&lt;/code&gt; containing the process id (&lt;code&gt;%p&lt;/code&gt;) so you can match &lt;code&gt;Process 8121&lt;/code&gt; to its query. If you ship logs to a hosted service, search for &lt;code&gt;deadlock detected&lt;/code&gt; and read the two statements side by side. The ordering mismatch is usually visible in thirty seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: turn on &lt;code&gt;log_lock_waits&lt;/code&gt; before you need it, because a deadlock you cannot attribute to two specific statements is a deadlock you cannot fix.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you fix it: consistent lock ordering
&lt;/h2&gt;

&lt;p&gt;Lock the rows you are about to modify up front, in a deterministic order, then do the writes. For the transfer example:&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;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;accounts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&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;id&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&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;Both directions of transfer now lock id 1 before id 2. The second transaction waits for the first instead of forming a cycle, and waiting is fine; only circular waiting is fatal.&lt;/p&gt;

&lt;p&gt;In application code, sort the keys before you touch them. A Python sketch with 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;psycopg&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transfer&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;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&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;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="c1"&gt;# Lock both rows in a fixed order, regardless of transfer direction.
&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 id FROM accounts WHERE id = ANY(%s) ORDER BY id FOR UPDATE&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;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&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;UPDATE accounts SET balance = balance - %s WHERE 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;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&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;UPDATE accounts SET balance = balance + %s WHERE 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;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&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;For batch jobs, the same idea applies: select the target ids with &lt;code&gt;ORDER BY id FOR UPDATE&lt;/code&gt;, or use &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; if workers should divide work rather than queue behind each other. Also keep transactions short. A transaction that holds locks while it calls an external API widens the window for every cycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: if every transaction acquires locks in one global order (for example ascending primary key), a lock cycle cannot form.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What should the retry look like?
&lt;/h2&gt;

&lt;p&gt;Ordering fixes the cycles you know about. Foreign-key and index locks can still produce rare ones, so wrap write transactions in a retry that catches only the deadlock error:&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="kn"&gt;import&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="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;errors&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_with_deadlock_retry&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;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&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;attempt&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="n"&gt;attempts&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fn&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="k"&gt;except&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;DeadlockDetected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# SQLSTATE 40P01
&lt;/span&gt;            &lt;span class="k"&gt;if&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;attempts&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;raise&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;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="mf"&gt;0.05&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="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retry the whole transaction, not the single statement that failed, because Postgres has already rolled it back. Make sure the function is safe to run twice, which means no external side effects inside it. Jitter matters: without it, the two transactions can collide again in lockstep.&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;Removes the cycle?&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Use when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Consistent lock order (&lt;code&gt;ORDER BY id FOR UPDATE&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;One extra statement per transaction&lt;/td&gt;
&lt;td&gt;Always, as the primary fix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry on &lt;code&gt;40P01&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No, it recovers&lt;/td&gt;
&lt;td&gt;Latency on the rare collision&lt;/td&gt;
&lt;td&gt;As a safety net behind ordering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shorter transactions&lt;/td&gt;
&lt;td&gt;Narrows the window&lt;/td&gt;
&lt;td&gt;Refactoring&lt;/td&gt;
&lt;td&gt;Locks held across slow calls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SKIP LOCKED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Avoids waiting&lt;/td&gt;
&lt;td&gt;Rows may be skipped this pass&lt;/td&gt;
&lt;td&gt;Worker pools draining a queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raise &lt;code&gt;deadlock_timeout&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Slower detection&lt;/td&gt;
&lt;td&gt;Rarely; it just delays the failure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: ordering prevents deadlocks, retries absorb the leftovers, and raising timeouts does neither.&lt;/strong&gt;&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is a Postgres deadlock a bug in my database?
&lt;/h3&gt;

&lt;p&gt;No. Postgres is behaving correctly by detecting the cycle and aborting one transaction; the bug is in the application's lock ordering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I just retry on deadlock detected?
&lt;/h3&gt;

&lt;p&gt;Retry as a safety net, but fix the ordering first. A retry on its own turns a correctness bug into intermittent latency spikes.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is SQLSTATE 40P01?
&lt;/h3&gt;

&lt;p&gt;It is the code Postgres returns for &lt;code&gt;deadlock_detected&lt;/code&gt;. Catch that code specifically instead of retrying on every error.&lt;/p&gt;

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

&lt;p&gt;If you see &lt;code&gt;deadlock detected&lt;/code&gt;, enable &lt;code&gt;log_lock_waits&lt;/code&gt;, find the two statements, and make both paths lock rows in the same order, usually ascending primary key via &lt;code&gt;SELECT ... ORDER BY id FOR UPDATE&lt;/code&gt;. Then wrap write transactions in a small jittered retry on &lt;code&gt;40P01&lt;/code&gt; for the cycles you cannot predict. Do not treat a longer &lt;code&gt;deadlock_timeout&lt;/code&gt; as a fix. As of late 2026 this behavior is stable across supported Postgres versions, though the exact log wording can differ slightly.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/is-your-cdn-actually-caching-audit-hit-ratio-and-asset-fan-out-before-you-migrate-buckets-4ef5"&gt;Is Your CDN Actually Caching? Audit Hit Ratio and Asset Fan-Out Before You Migrate Buckets&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/cursor-vs-github-copilot-vs-windsurf-which-ai-editor-fits-how-you-actually-work-1ih9"&gt;Cursor vs GitHub Copilot vs Windsurf: Which AI Editor Fits How You Actually Work?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/railway-vs-render-vs-flyio-deploying-a-hobby-app-without-a-devops-team-10d3"&gt;Railway vs Render vs Fly.io: Deploying a Hobby App Without a DevOps Team&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>backend</category>
    </item>
    <item>
      <title>Postgres "could not resize shared memory segment: No space left on device" in Docker: Why It Happens and How to Fix It</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Thu, 15 Oct 2026 22:11:16 +0000</pubDate>
      <link>https://dev.to/libme/postgres-could-not-resize-shared-memory-segment-no-space-left-on-device-in-docker-why-it-54cg</link>
      <guid>https://dev.to/libme/postgres-could-not-resize-shared-memory-segment-no-space-left-on-device-in-docker-why-it-54cg</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; this error means a parallel query tried to allocate more shared memory than the container's &lt;code&gt;/dev/shm&lt;/code&gt; allows. Docker gives every container a 64MB &lt;code&gt;/dev/shm&lt;/code&gt; by default, regardless of how much RAM the host has. Raise it with &lt;code&gt;shm_size&lt;/code&gt; (Docker/Compose) or a memory-backed &lt;code&gt;emptyDir&lt;/code&gt; mounted at &lt;code&gt;/dev/shm&lt;/code&gt; (Kubernetes); disabling parallelism is a stopgap, not a fix.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  could not resize shared memory segment "/PostgreSQL.1672401134" to 67146816 bytes: No space left on device
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The confusing part is that the disk is not full. &lt;code&gt;df -h&lt;/code&gt; on your volumes looks fine, the container has gigabytes of memory headroom, and the same query works on your laptop's native Postgres install. It also tends to be intermittent: small queries pass, a bigger report query or a larger dataset after a data load triggers it.&lt;/p&gt;

&lt;p&gt;The "No space left on device" refers to &lt;code&gt;/dev/shm&lt;/code&gt;, a tmpfs. Check it from inside the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; my-postgres &lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /dev/shm
&lt;span class="c"&gt;# Filesystem      Size  Used Avail Use% Mounted on&lt;/span&gt;
&lt;span class="c"&gt;# shm              64M     0   64M   0% /dev/shm&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see 64M, that is your ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a parallel query need /dev/shm?
&lt;/h2&gt;

&lt;p&gt;When the planner chooses a parallel plan, the leader process and its workers need to share data: tuple queues, and for a Parallel Hash join, the hash table itself. Postgres allocates that as dynamic shared memory. On Linux the default &lt;code&gt;dynamic_shared_memory_type&lt;/code&gt; is &lt;code&gt;posix&lt;/code&gt;, which creates segments named &lt;code&gt;/PostgreSQL.&amp;lt;number&amp;gt;&lt;/code&gt; in &lt;code&gt;/dev/shm&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You can confirm a query is a candidate by looking for parallel nodes:&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="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;SELECT&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_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;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;order_items&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&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;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Look for: Gather, Workers Planned: 2, Parallel Hash Join&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Parallel Hash builds one shared hash table sized by &lt;code&gt;work_mem&lt;/code&gt; multiplied by the number of participants (as of Postgres 13+ the shared hash table can use up to &lt;code&gt;work_mem × hash_mem_multiplier&lt;/code&gt; per participant). With &lt;code&gt;work_mem = 64MB&lt;/code&gt; and two workers plus the leader, one query can ask for hundreds of megabytes of shared memory. Against a 64MB tmpfs, that fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the error is a tmpfs size limit that only parallel plans with large hash tables hit, which is why it shows up after data growth rather than on day one.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the fixes, and which one should I use?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Cost / drawback&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Raise &lt;code&gt;/dev/shm&lt;/code&gt; size&lt;/td&gt;
&lt;td&gt;Gives parallel queries the memory they were planned for&lt;/td&gt;
&lt;td&gt;Shared memory counts toward container memory in some setups; size it deliberately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;max_parallel_workers_per_gather = 0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stops parallel plans entirely&lt;/td&gt;
&lt;td&gt;Slower large scans and aggregations; hides the problem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lower &lt;code&gt;work_mem&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Smaller hash tables, fewer shm bytes&lt;/td&gt;
&lt;td&gt;Can push sorts and hashes to disk, slowing other queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dynamic_shared_memory_type = mmap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Backs segments with files under &lt;code&gt;pg_dynshmem&lt;/code&gt; instead of &lt;code&gt;/dev/shm&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Disk-backed, so slower, and not recommended for production by the Postgres docs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For almost everyone, the first row is the right answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker and Compose
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
    &lt;span class="na"&gt;shm_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;256mb&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With plain &lt;code&gt;docker run&lt;/code&gt;, the flag is &lt;code&gt;--shm-size=256m&lt;/code&gt;. Recreate the container (a restart is not enough if you changed the Compose definition; run &lt;code&gt;docker compose up -d&lt;/code&gt;) and re-check &lt;code&gt;df -h /dev/shm&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;How big? Start from your worst parallel query: roughly &lt;code&gt;(workers + 1) × work_mem × hash_mem_multiplier&lt;/code&gt; is a reasonable upper bound for one hash join, and concurrent queries add up. I would rather size it to a few hundred MB and watch than set it to something huge, because tmpfs pages are real memory once written.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubernetes
&lt;/h3&gt;

&lt;p&gt;Kubernetes has no &lt;code&gt;shm_size&lt;/code&gt; field. The standard approach is an in-memory &lt;code&gt;emptyDir&lt;/code&gt; mounted over &lt;code&gt;/dev/shm&lt;/code&gt;:&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;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:16&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2Gi&lt;/span&gt;
      &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dshm&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/dev/shm&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dshm&lt;/span&gt;
      &lt;span class="na"&gt;emptyDir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;medium&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Memory&lt;/span&gt;
        &lt;span class="na"&gt;sizeLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The thing that tripped me up here: memory-backed &lt;code&gt;emptyDir&lt;/code&gt; usage is charged against the container's memory limit. If shared memory fills while &lt;code&gt;shared_buffers&lt;/code&gt; and backend memory are already near the limit, you swap a "No space left on device" error for an OOMKill. Leave headroom in &lt;code&gt;limits.memory&lt;/code&gt; beyond what you used to give the pod.&lt;/p&gt;

&lt;p&gt;If you run Postgres through an operator such as CloudNativePG or Zalando's postgres-operator, check whether it already mounts &lt;code&gt;/dev/shm&lt;/code&gt; for you before adding your own volume; operators commonly handle this and a duplicate mount will conflict.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: raise &lt;code&gt;/dev/shm&lt;/code&gt; to a few hundred MB and treat that memory as part of the container's budget, not free space.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I verify the fix?
&lt;/h2&gt;

&lt;p&gt;Re-run the failing query and confirm the plan is still parallel, so you did not "fix" it by silently turning parallelism off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; my-postgres psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"SHOW dynamic_shared_memory_type;"&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; my-postgres &lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /dev/shm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run &lt;code&gt;EXPLAIN (ANALYZE)&lt;/code&gt; and check that &lt;code&gt;Workers Launched&lt;/code&gt; matches &lt;code&gt;Workers Planned&lt;/code&gt;. If launched is lower than planned, the cluster is hitting &lt;code&gt;max_parallel_workers&lt;/code&gt;, which is a different limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do managed databases have this problem?
&lt;/h2&gt;

&lt;p&gt;Not in a way you control. On managed offerings such as Amazon RDS for PostgreSQL, shared memory is part of the instance configuration, so you will not edit &lt;code&gt;/dev/shm&lt;/code&gt; yourself. If you want to avoid owning this kind of container-level detail entirely, Amazon RDS for PostgreSQL is one option that removes it; the tradeoff is less control over kernel-level settings and a bill that scales with instance class. For a local dev database or a small self-managed deployment, the fix above is a one-line change and not a reason to migrate.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why does Postgres say "No space left on device" when my disk is empty?&lt;/strong&gt;&lt;br&gt;
Because the error refers to &lt;code&gt;/dev/shm&lt;/code&gt;, an in-memory tmpfs, not your data volume. Docker defaults it to 64MB.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What should I set &lt;code&gt;shm_size&lt;/code&gt; to for Postgres in Docker?&lt;/strong&gt;&lt;br&gt;
256MB is a sensible starting point for development and modest workloads. Size up if your parallel queries use large &lt;code&gt;work_mem&lt;/code&gt; values, and verify with &lt;code&gt;df -h /dev/shm&lt;/code&gt; during a heavy query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to set &lt;code&gt;max_parallel_workers_per_gather = 0&lt;/code&gt; to make the error go away?&lt;/strong&gt;&lt;br&gt;
It stops the error, but it also disables parallel query for that setting's scope, so large scans and aggregates will run slower. Use it to unblock yourself, then raise &lt;code&gt;/dev/shm&lt;/code&gt; and turn parallelism back on.&lt;/p&gt;

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

&lt;p&gt;If you run Postgres in a container and see "could not resize shared memory segment," set &lt;code&gt;shm_size&lt;/code&gt; in Docker or mount a memory-backed &lt;code&gt;emptyDir&lt;/code&gt; at &lt;code&gt;/dev/shm&lt;/code&gt; in Kubernetes, and budget that memory inside your limits. Use &lt;code&gt;max_parallel_workers_per_gather = 0&lt;/code&gt; only as a temporary workaround, and avoid &lt;code&gt;mmap&lt;/code&gt; shared memory for production. If you would rather not manage container internals at all, a managed Postgres service removes this class of problem at the cost of control.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/ci-secrets-are-empty-in-fork-pull-requests-the-safe-patterns-that-actually-work-2ood"&gt;CI Secrets Are Empty in Fork Pull Requests: The Safe Patterns That Actually Work&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;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;/ul&gt;

</description>
      <category>postgres</category>
      <category>docker</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>npm ci Says package.json and package-lock.json Are Not in Sync: Causes, Fixes, and How pnpm and Yarn Compare</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Wed, 14 Oct 2026 22:15:26 +0000</pubDate>
      <link>https://dev.to/libme/npm-ci-says-packagejson-and-package-lockjson-are-not-in-sync-causes-fixes-and-how-pnpm-and-161o</link>
      <guid>https://dev.to/libme/npm-ci-says-packagejson-and-package-lockjson-are-not-in-sync-causes-fixes-and-how-pnpm-and-161o</guid>
      <description>&lt;p&gt;&lt;code&gt;npm ci&lt;/code&gt; fails with "package.json and package-lock.json are not in sync" when the lockfile cannot satisfy what &lt;code&gt;package.json&lt;/code&gt; declares, or when it was written by a different npm version than the one running in CI. The fix is almost always to regenerate the lockfile with the same npm version CI uses, commit it, and pin that version so it cannot drift again. This post covers the four real causes, a diagnostic order, and how npm, pnpm and Yarn differ in enforcing lockfiles (as of October 2026).&lt;/p&gt;

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

&lt;p&gt;The message looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm error `npm ci` can only install packages when your package.json and
package-lock.json or npm-shrinkwrap.json are in sync. Please update your
lock file with `npm install` before continuing.
npm error Missing: zod@3.23.8 from lock file
npm error Invalid: lock file's esbuild@0.21.5 does not satisfy esbuild@0.24.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unlike &lt;code&gt;npm install&lt;/code&gt;, &lt;code&gt;npm ci&lt;/code&gt; never edits the lockfile. It deletes &lt;code&gt;node_modules&lt;/code&gt;, compares the lockfile against &lt;code&gt;package.json&lt;/code&gt;, and refuses to proceed on any mismatch. That strictness is the feature: CI should fail loudly instead of quietly resolving new versions.&lt;/p&gt;

&lt;p&gt;The takeaway: this error is &lt;code&gt;npm ci&lt;/code&gt; doing its job, so the real question is why the two files disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do the two files disagree?
&lt;/h2&gt;

&lt;p&gt;In rough order of how often I see them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Someone edited &lt;code&gt;package.json&lt;/code&gt; by hand&lt;/strong&gt; and never ran &lt;code&gt;npm install&lt;/code&gt;, so the lockfile has no entry for the new range.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge conflicts resolved badly.&lt;/strong&gt; Two branches both changed dependencies, and the lockfile conflict was fixed by picking one side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different npm versions.&lt;/strong&gt; Older npm majors write older &lt;code&gt;lockfileVersion&lt;/code&gt; formats and resolve peer dependencies differently. A laptop on one major and CI on another can each consider the other's lockfile incomplete.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform-specific optional dependencies.&lt;/strong&gt; Packages such as esbuild, Rollup, SWC and Lightning CSS ship native binaries as optional dependencies per OS and CPU. A lockfile generated on macOS can lack the Linux entries, producing errors like &lt;code&gt;Cannot find module @rollup/rollup-linux-x64-gnu&lt;/code&gt; in CI.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The takeaway: three of the four causes are human workflow problems, and only the optional-dependency one is a genuine tooling quirk.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I diagnose it in the right order?
&lt;/h2&gt;

&lt;p&gt;Start by making the failure reproducible locally with the exact versions CI uses:&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;# Match CI's Node/npm first&lt;/span&gt;
node &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nt"&gt;-v&lt;/span&gt;

&lt;span class="c"&gt;# Reproduce in a clean clone, not your working directory&lt;/span&gt;
git clone &lt;span class="nb"&gt;.&lt;/span&gt; /tmp/repro &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /tmp/repro
npm ci
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it fails in a clean clone, the committed lockfile is the problem. If it passes locally but fails in CI, the npm versions differ. Compare the &lt;code&gt;lockfileVersion&lt;/code&gt; field at the top of &lt;code&gt;package-lock.json&lt;/code&gt; against what CI's npm would write.&lt;/p&gt;

&lt;p&gt;To fix cases 1 to 3:&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;# Use the same npm major as CI&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt;            &lt;span class="c"&gt;# reconciles the lockfile with package.json&lt;/span&gt;
git add package-lock.json
npm ci                 &lt;span class="c"&gt;# must pass before you push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For case 4, a cleanly regenerated lockfile on a recent npm often includes the other platforms' optional entries. If it does not, delete &lt;code&gt;node_modules&lt;/code&gt; and &lt;code&gt;package-lock.json&lt;/code&gt; once, reinstall, and verify with &lt;code&gt;npm ci&lt;/code&gt; inside a Linux container that matches CI. Dead ends I wasted time on: &lt;code&gt;npm ci --force&lt;/code&gt; and &lt;code&gt;--legacy-peer-deps&lt;/code&gt; do not repair a stale lockfile. They change resolution rules and can hide the drift rather than remove it.&lt;/p&gt;

&lt;p&gt;The takeaway: reproduce in a clean clone with CI's exact npm version before touching the lockfile.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I stop it from coming back?
&lt;/h2&gt;

&lt;p&gt;Pin the tool version in the repository so laptops and CI cannot disagree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"packageManager"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm@10.9.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"engines"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;=20"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"npm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;=10"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;packageManager&lt;/code&gt; field is read by Corepack, and &lt;code&gt;engines&lt;/code&gt; only warns by default unless &lt;code&gt;engine-strict&lt;/code&gt; is set in &lt;code&gt;.npmrc&lt;/code&gt;. Then add a cheap guard job that fails pull requests for drift before the slow pipeline starts:&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="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/setup-node@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;node-version-file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.nvmrc&lt;/span&gt;
    &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm&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;npm ci --ignore-scripts&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;git diff --exit-code package-lock.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line catches the case where install silently rewrote the lockfile locally but nobody committed it.&lt;/p&gt;

&lt;p&gt;The takeaway: a pinned package manager version removes the most common cause, and &lt;code&gt;git diff --exit-code&lt;/code&gt; on the lockfile catches the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do npm, pnpm and Yarn enforce lockfiles?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Strict CI command&lt;/th&gt;
&lt;th&gt;Behavior on mismatch&lt;/th&gt;
&lt;th&gt;Notable limitation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;npm&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npm ci&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Always fails, never writes lockfile&lt;/td&gt;
&lt;td&gt;Output can be verbose; sensitive to npm version differences&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pnpm&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pnpm install --frozen-lockfile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fails; this is the default when pnpm detects a CI environment&lt;/td&gt;
&lt;td&gt;Different &lt;code&gt;node_modules&lt;/code&gt; layout can expose code relying on undeclared dependencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yarn (Berry)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yarn install --immutable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fails if the lockfile would change&lt;/td&gt;
&lt;td&gt;Migration from classic Yarn changes config and plugins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yarn (classic)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yarn install --frozen-lockfile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fails&lt;/td&gt;
&lt;td&gt;Classic is in maintenance mode&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you want strict lockfile enforcement without remembering a flag, pnpm is the one that turns it on by default in CI. Its real cost is that its non-flat &lt;code&gt;node_modules&lt;/code&gt; will surface imports of packages you never declared, which means a migration day of fixing those. If you stay on npm, &lt;code&gt;npm ci&lt;/code&gt; is already strict enough; your gap is version pinning, not the tool. Yarn Berry's &lt;code&gt;--immutable&lt;/code&gt; is equally strict, but expect to learn its configuration model.&lt;/p&gt;

&lt;p&gt;The takeaway: all three package managers can fail CI on drift, so switching tools matters less than pinning the tool version and running the strict command.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why does npm ci fail but npm install works?
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;npm install&lt;/code&gt; updates the lockfile to match &lt;code&gt;package.json&lt;/code&gt;, hiding the mismatch, while &lt;code&gt;npm ci&lt;/code&gt; refuses to modify it and fails instead. Run &lt;code&gt;npm install&lt;/code&gt;, commit the updated lockfile, and &lt;code&gt;npm ci&lt;/code&gt; will pass.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I delete package-lock.json to fix npm ci?
&lt;/h3&gt;

&lt;p&gt;Only as a last resort, because regenerating from scratch re-resolves every dependency to the newest version its range allows. Try &lt;code&gt;npm install&lt;/code&gt; with the CI-matching npm version first, which keeps existing resolutions where possible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does npm ci fail on Linux with a missing @rollup or esbuild binary?
&lt;/h3&gt;

&lt;p&gt;The lockfile was generated on another OS and lacks the Linux optional dependency entries. Regenerate the lockfile with a recent npm and verify with &lt;code&gt;npm ci&lt;/code&gt; in a Linux container.&lt;/p&gt;

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

&lt;p&gt;If you use npm, pin the npm version with &lt;code&gt;packageManager&lt;/code&gt; and make &lt;code&gt;npm ci&lt;/code&gt; plus a lockfile diff check your first CI step. If drift keeps happening on a team with mixed machines, pnpm's default frozen-lockfile behavior in CI removes one class of mistakes, at the price of a migration. Yarn Berry fits teams already invested in it. Whichever you pick, never paper over this error with &lt;code&gt;--force&lt;/code&gt;.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/why-your-oauth-integration-randomly-returns-invalidgrant-and-how-to-stop-two-workers-from-racing-4ake"&gt;Why Your OAuth Integration Randomly Returns invalid_grant (and How to Stop Two Workers From Racing)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/an-ai-assisted-code-review-pipeline-that-catches-what-humans-skim-past-5hc0"&gt;An AI-Assisted Code Review Pipeline That Catches What Humans Skim Past&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/curl-returns-200-but-your-http-client-gets-a-404-debugging-vary-and-cdn-cache-variants-38je"&gt;curl Returns 200 but Your HTTP Client Gets a 404: Debugging Vary and CDN Cache Variants&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>cicd</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Intermittent 502s and "read ECONNRESET" Behind a Load Balancer: The Keep-Alive Timeout Mismatch</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Tue, 13 Oct 2026 22:14:30 +0000</pubDate>
      <link>https://dev.to/libme/intermittent-502s-and-read-econnreset-behind-a-load-balancer-the-keep-alive-timeout-mismatch-1b3n</link>
      <guid>https://dev.to/libme/intermittent-502s-and-read-econnreset-behind-a-load-balancer-the-keep-alive-timeout-mismatch-1b3n</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; if you see a trickle of 502s (or &lt;code&gt;read ECONNRESET&lt;/code&gt; / &lt;code&gt;socket hang up&lt;/code&gt; in clients) that has no pattern in your app logs, check whether your app server closes idle connections sooner than the proxy in front of it. The rule: the component &lt;em&gt;behind&lt;/em&gt; must keep idle connections open longer than the component &lt;em&gt;in front&lt;/em&gt;. Node's default &lt;code&gt;keepAliveTimeout&lt;/code&gt; is 5 seconds; many load balancers default to 60. That gap is a race condition, and it only fires under moderate traffic.&lt;/p&gt;

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

&lt;p&gt;The symptoms I've seen, and that are commonly reported for this problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A low, steady rate of 502 Bad Gateway (or 504) responses from the proxy, often well under 1% of requests.&lt;/li&gt;
&lt;li&gt;The app's own access log shows nothing for those requests. They never arrived, or arrived on a socket that was already dead.&lt;/li&gt;
&lt;li&gt;Errors cluster in "quiet-ish" periods, not at peak. At peak, connections are reused so fast they never idle out. At zero traffic, nothing is sent. The middle is where it hurts.&lt;/li&gt;
&lt;li&gt;Client-side, you may see &lt;code&gt;Error: read ECONNRESET&lt;/code&gt; or &lt;code&gt;Error: socket hang up&lt;/code&gt; from Node, &lt;code&gt;ConnectionResetError: [Errno 104] Connection reset by peer&lt;/code&gt; from Python, or &lt;code&gt;upstream prematurely closed connection while reading response header from upstream&lt;/code&gt; in the nginx error log.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dead ends I (and most people) try first: raising app memory, adding retries in the app, blaming the database, increasing proxy read timeouts. None of those touch the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does it happen?
&lt;/h2&gt;

&lt;p&gt;HTTP keep-alive lets the proxy reuse one TCP connection to your app for many requests. Both sides run an idle timer. If your app's timer is 5 seconds and the proxy's is 60, the proxy happily believes a connection is still good at second 5.0 while your app is closing it. If the proxy sends a request in that exact window, the request lands on a socket whose FIN is already in flight. The app never processes it; the proxy sees a reset and returns 502.&lt;/p&gt;

&lt;p&gt;The window is tiny, which is why the error rate is low and why it looks random. It also scales with traffic volume in the "moderate" band, because you need enough requests for some to land in the window, but enough gaps for connections to idle at all.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Takeaway: an idle-timeout mismatch is a race, so it produces rare, patternless errors that no amount of app-level debugging will explain.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Which defaults conflict? (as of October 2026)
&lt;/h2&gt;

&lt;p&gt;Defaults change between versions, so verify against your own versions. These are the ones I'd check first:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Typical default&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Node.js &lt;code&gt;http&lt;/code&gt; server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;server.keepAliveTimeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5 seconds&lt;/td&gt;
&lt;td&gt;Too short for most proxies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node.js &lt;code&gt;http&lt;/code&gt; server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;server.headersTimeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60 seconds (in recent versions)&lt;/td&gt;
&lt;td&gt;Must exceed &lt;code&gt;keepAliveTimeout&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Application Load Balancer&lt;/td&gt;
&lt;td&gt;idle timeout&lt;/td&gt;
&lt;td&gt;60 seconds&lt;/td&gt;
&lt;td&gt;Configurable per load balancer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nginx (as a proxy to upstream)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;keepalive_timeout&lt;/code&gt; inside &lt;code&gt;upstream {}&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;60 seconds&lt;/td&gt;
&lt;td&gt;Only applies if you enable upstream &lt;code&gt;keepalive&lt;/code&gt; connections&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gunicorn&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--keep-alive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2 seconds&lt;/td&gt;
&lt;td&gt;Very short; matters when a proxy reuses connections&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go &lt;code&gt;net/http&lt;/code&gt; server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IdleTimeout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;falls back to &lt;code&gt;ReadTimeout&lt;/code&gt;, else no limit&lt;/td&gt;
&lt;td&gt;Set it explicitly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The correct ordering is always: &lt;strong&gt;client idle &amp;lt; proxy idle &amp;lt; app idle&lt;/strong&gt;, measured at each hop. So for an ALB at 60 seconds, the app should be comfortably above that.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you fix it in Node.js?
&lt;/h2&gt;

&lt;p&gt;Set the app's keep-alive timeout above the proxy's idle timeout, and keep &lt;code&gt;headersTimeout&lt;/code&gt; above 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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Proxy (e.g. an ALB) idles out at 60s, so stay above it.&lt;/span&gt;
&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keepAliveTimeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;65&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// headersTimeout must be greater than keepAliveTimeout, or Node can&lt;/span&gt;
&lt;span class="c1"&gt;// still drop connections early.&lt;/span&gt;
&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headersTimeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;66&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Frameworks like Express and Fastify sit on top of this, so you set it on the underlying &lt;code&gt;http.Server&lt;/code&gt; (the value returned by &lt;code&gt;app.listen()&lt;/code&gt; in Express). Fastify exposes &lt;code&gt;keepAliveTimeout&lt;/code&gt; as a server option.&lt;/p&gt;

&lt;p&gt;For Go, set it explicitly instead of inheriting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;srv&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Addr&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="s"&gt;":3000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IdleTimeout&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;65&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Gunicorn, &lt;code&gt;--keep-alive 65&lt;/code&gt; (and note that the default sync worker doesn't really support keep-alive; a threaded or async worker class does).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Takeaway: pick one number for the proxy's idle timeout, then set every server behind it to at least 5 seconds more.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you prove it's the cause before changing anything?
&lt;/h2&gt;

&lt;p&gt;Don't just apply the fix and hope. A quick reproduction: set the app timeout very low and hammer it at intervals near that value.&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;// repro.js: expects the server above with keepAliveTimeout = 1000&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;keepAlive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxSockets&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;function&lt;/span&gt; &lt;span class="nf"&gt;hit&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;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&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;http&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="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;localhost&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;end&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="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="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;results&lt;/span&gt; &lt;span class="o"&gt;=&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// Wait close to the server's idle timeout to land in the race window&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ECONNRESET shows up in the tally&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the server timeout at 1 second and the client waiting about 1 second, you should see some &lt;code&gt;ECONNRESET&lt;/code&gt; results. Raise the server timeout to 5 seconds and rerun, and they should disappear. You are reproducing the race at small scale. In production, correlate by checking the proxy's logs: ALB access logs record the backend status as &lt;code&gt;-&lt;/code&gt; for these cases, and nginx logs the &lt;code&gt;prematurely closed&lt;/code&gt; line above.&lt;/p&gt;

&lt;p&gt;One more detail: since Node 19, the global HTTP agent uses keep-alive by default, so outbound calls from your Node services to &lt;em&gt;other&lt;/em&gt; services can hit the same race in the opposite direction. If a downstream closes idle sockets quickly, you get &lt;code&gt;ECONNRESET&lt;/code&gt; on your side. Retrying idempotent requests once on &lt;code&gt;ECONNRESET&lt;/code&gt; is a reasonable second line of defense, but fix the timeout first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is a managed load balancer worth it for this?
&lt;/h2&gt;

&lt;p&gt;The mismatch exists with any proxy, managed or self-hosted. The difference is visibility. If you want the idle timeout as a single documented setting you can read in one place, AWS Application Load Balancer is the one that exposes it per load balancer with a clear default. Its limitation is that it only solves your side of the bargain; it can't know what your app does, and the fix still lives in your code. Self-hosted nginx gives more control, including upstream keepalive tuning, but you own every timeout and every default across versions.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why do I get intermittent 502 errors from my load balancer when my app logs show nothing?&lt;/strong&gt;&lt;br&gt;
Most often the app closed an idle keep-alive connection just as the load balancer reused it, so the request never reached your code. Raise the app's keep-alive timeout above the load balancer's idle timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What should Node.js &lt;code&gt;keepAliveTimeout&lt;/code&gt; be set to behind an AWS ALB?&lt;/strong&gt;&lt;br&gt;
Higher than the ALB idle timeout, which defaults to 60 seconds, so something like 65 seconds, with &lt;code&gt;headersTimeout&lt;/code&gt; set slightly above that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is &lt;code&gt;read ECONNRESET&lt;/code&gt; always a keep-alive problem?&lt;/strong&gt;&lt;br&gt;
No. It also appears when a process crashes, a container is OOM-killed, or a firewall drops idle connections. The keep-alive race is the likely cause when it is rare, patternless, and your app logs and metrics look healthy.&lt;/p&gt;

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

&lt;p&gt;If you run Node, Gunicorn, or any app server behind a load balancer or reverse proxy, set the app's keep-alive timeout explicitly rather than inheriting defaults, and make it longer than the proxy's idle timeout. Reproduce with a low timeout if you need to convince yourself or your team. If errors persist after the fix, move on to crashes, memory limits, and network devices that drop idle flows.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/dependabot-vs-renovate-which-one-stops-the-monday-morning-pr-flood-47ao"&gt;Dependabot vs Renovate: Which One Stops the Monday Morning PR Flood?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/postgres-multi-tenancy-row-level-security-tenantid-filters-or-a-schema-per-tenant-11j9"&gt;Postgres Multi-Tenancy: Row-Level Security, tenant_id Filters, or a Schema per Tenant?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/your-postgres-backups-are-untested-until-you-restore-one-a-drill-for-small-teams-3paj"&gt;Your Postgres Backups Are Untested Until You Restore One: A Drill for Small Teams&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>node</category>
      <category>backend</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Scale-to-Zero vs Provisioned Managed Postgres: Which Pricing Model Fits Your Traffic?</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Mon, 12 Oct 2026 17:10:58 +0000</pubDate>
      <link>https://dev.to/libme/scale-to-zero-vs-provisioned-managed-postgres-which-pricing-model-fits-your-traffic-enf</link>
      <guid>https://dev.to/libme/scale-to-zero-vs-provisioned-managed-postgres-which-pricing-model-fits-your-traffic-enf</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Scale-to-zero Postgres (billed mostly on compute time actually used) wins when your database is idle most of the day, such as side projects, staging, and per-PR previews. A provisioned instance (billed per hour whether or not anyone queries it) wins once your database is busy for a large fraction of the month, because you stop paying a premium for elasticity you no longer use. The break-even is a ratio of active hours to total hours, and you can compute it in ten lines of code with your own provider's current rates.&lt;/p&gt;

&lt;p&gt;I'm deliberately not publishing a price table. Rates change, and a stale number in a reference post does more harm than none. What lasts is the &lt;em&gt;shape&lt;/em&gt; of each billing model, and that shape is what decides your bill. Everything provider-specific below is as of October 2026; check the pricing page before you commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the three billing models for managed Postgres?
&lt;/h2&gt;

&lt;p&gt;Almost every managed Postgres offering falls into one of these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Provisioned instance.&lt;/strong&gt; You pick an instance size and pay per hour while it exists, plus storage, backup storage, and sometimes provisioned IOPS. Amazon RDS for PostgreSQL is the canonical example. Running idle costs the same as running flat out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan-based project.&lt;/strong&gt; You pay a flat monthly tier per project that bundles a compute size, storage and bandwidth, with overage charges beyond the bundle. Supabase works this way as of this writing. Free-tier projects have historically been paused after a period of inactivity, which is a limitation, not a feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usage-based, scale-to-zero.&lt;/strong&gt; Compute is billed by the time (and size) it is actually running, and it suspends when idle. Neon is the best-known Postgres example, and Aurora Serverless v2 added the ability to scale down to zero capacity with auto-pause in late 2024.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The key takeaway: the pricing model decides your bill more than the headline "per GB" or "per vCPU" rate does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does scale-to-zero stop being cheaper?
&lt;/h2&gt;

&lt;p&gt;Two mechanics erode the advantage, and both bit me when I modeled a small production workload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate premium.&lt;/strong&gt; Per-hour compute on elastic offerings is usually priced higher than the equivalent always-on capacity, because the provider is carrying idle capacity for you. That premium is invisible while your database sleeps 20 hours a day. It dominates once it sleeps 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cold starts keep it awake.&lt;/strong&gt; A scale-to-zero database suspends after an idle timeout. If anything pings it more often than that timeout (a health check, a cron job, a monitoring probe, an ORM that opens a connection at boot), it never sleeps, and you are paying the elastic premium for 24 hours a day. If you adopt this model, the first thing to audit is who is touching the database on a schedule. I covered the health-check side of that in a separate post on deep health checks; the same probe that makes monitoring honest can quietly defeat auto-suspend.&lt;/p&gt;

&lt;p&gt;The key takeaway: scale-to-zero only saves money if the database actually reaches zero, so find every periodic connection before you assume it will.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I compute the break-even for my workload?
&lt;/h2&gt;

&lt;p&gt;Plug in your provider's current numbers. The script below compares a provisioned instance against an elastic one at a given active-time fraction, and finds the fraction where they cross.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;monthly_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;active_fraction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;elastic_rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provisioned_rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="n"&gt;storage_gb&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;storage_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;730&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Rates are dollars per compute-hour. Replace with current provider numbers.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;elastic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;elastic_rate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;hours&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;active_fraction&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;storage_gb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;storage_rate&lt;/span&gt;
    &lt;span class="n"&gt;provisioned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;provisioned_rate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;hours&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;storage_gb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;storage_rate&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;elastic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provisioned&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;break_even&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;elastic_rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;provisioned_rate&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Elastic is cheaper while active_fraction &amp;lt; provisioned_rate / elastic_rate
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;provisioned_rate&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;elastic_rate&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="c1"&gt;# Placeholder rates for illustration only: NOT real provider prices.
&lt;/span&gt;    &lt;span class="n"&gt;ELASTIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PROVISIONED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.10&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;break-even active fraction: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;break_even&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ELASTIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PROVISIONED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&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="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;frac&lt;/span&gt; &lt;span class="ow"&gt;in&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="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.00&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;monthly_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ELASTIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PROVISIONED&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;active &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;frac&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  elastic $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;7.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;  provisioned $&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;7.2&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the made-up rates above, the elastic option is cheaper until the database is active about 62% of the month. The numbers are fake on purpose; the structure is the point. If your elastic rate is 1.6 times your provisioned rate, you need to be idle more than roughly 38% of the time to come out ahead. Real offerings also add minimum compute floors, storage that is billed even when compute is asleep, and per-project fees, so extend the function before trusting it.&lt;/p&gt;

&lt;p&gt;The key takeaway: the break-even active fraction equals the provisioned hourly rate divided by the elastic hourly rate, and nothing else about the sticker price matters until you know it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which model fits which workload?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Best-fit model&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;th&gt;Main drawback&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Side project, a few visits a day&lt;/td&gt;
&lt;td&gt;Scale-to-zero&lt;/td&gt;
&lt;td&gt;Sleeps most of the month&lt;/td&gt;
&lt;td&gt;Cold-start latency on the first request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staging and per-PR preview databases&lt;/td&gt;
&lt;td&gt;Scale-to-zero&lt;/td&gt;
&lt;td&gt;Dozens of mostly idle copies&lt;/td&gt;
&lt;td&gt;Branching and cleanup need discipline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Early-stage app with steady daily use&lt;/td&gt;
&lt;td&gt;Plan-based project&lt;/td&gt;
&lt;td&gt;Predictable monthly line item&lt;/td&gt;
&lt;td&gt;Overage charges and plan jumps are lumpy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production with constant traffic, 24/7&lt;/td&gt;
&lt;td&gt;Provisioned instance&lt;/td&gt;
&lt;td&gt;No elasticity premium, tunable parameters&lt;/td&gt;
&lt;td&gt;You pay for idle headroom and manage sizing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spiky B2B app, busy in work hours only&lt;/td&gt;
&lt;td&gt;Scale-to-zero or plan-based&lt;/td&gt;
&lt;td&gt;Nights and weekends are free&lt;/td&gt;
&lt;td&gt;Autoscaling can lag behind a sudden burst&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Preview environments are where scale-to-zero is hardest to beat. If you are creating a database per pull request, a provisioned instance per PR is absurd, and a branchable, suspend-on-idle database is the natural fit. I wrote about seeding those environments without cloning production data in an earlier post.&lt;/p&gt;

&lt;p&gt;The key takeaway: match the billing model to the duty cycle of the workload, not to the size of the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the hidden costs that aren't compute?
&lt;/h2&gt;

&lt;p&gt;Compute is rarely the surprise. These are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storage billed while compute sleeps.&lt;/strong&gt; Suspended does not mean free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups and point-in-time recovery.&lt;/strong&gt; Retention windows are priced separately on most providers; longer windows cost more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-AZ or high availability.&lt;/strong&gt; On RDS, a standby replica roughly doubles the compute line. Whether you need it is a real decision, not a default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connection limits and pooling.&lt;/strong&gt; Small compute sizes have low connection caps, which pushes you toward a pooler. Serverless functions make this worse, as covered in the post on transaction-mode pooling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Egress.&lt;/strong&gt; Data leaving the provider's network is billed on most clouds, and cross-region traffic more so.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these appear in the "from $X per month" headline.&lt;/p&gt;

&lt;p&gt;The key takeaway: price the full bundle (compute, storage, backups, HA, egress) at your expected duty cycle, because the headline number covers only the first item.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Is scale-to-zero Postgres production-ready?&lt;/strong&gt;&lt;br&gt;
Yes for workloads that tolerate a cold start of up to a second or so on the first request after idle time, and no for latency-sensitive paths that can't. Measure your own cold-start time on your provider and region; don't trust a blog post, including this one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is my scale-to-zero database never suspending?&lt;/strong&gt;&lt;br&gt;
Something is connecting more often than the idle timeout, usually a health check, cron job, monitoring probe, or connection pool with keepalive. Check the connection logs for recurring clients and lengthen or remove the periodic connection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is RDS cheaper than Neon or Supabase?&lt;/strong&gt;&lt;br&gt;
It depends on the duty cycle. RDS is typically cheaper per active hour for a database that is busy around the clock, while scale-to-zero and plan-based options win when the database is idle for much of the month.&lt;/p&gt;

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

&lt;p&gt;If your database is idle most of the day, start with a scale-to-zero option such as Neon, and audit every scheduled connection so it can actually suspend. If you want a flat monthly bill bundled with auth and storage for an early product, Supabase is the plan-based choice, with the caveat that plan jumps and overages are lumpy. If your database is busy most of the month and you need tuned parameters or high availability, a provisioned Amazon RDS for PostgreSQL instance is usually the cheaper and more controllable home. Whichever you pick, run the break-even script with this month's real rates and re-run it when your traffic shape changes.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/zapier-vs-make-vs-n8n-when-paying-per-task-stops-making-sense-327b"&gt;Zapier vs Make vs n8n: When Paying Per Task Stops Making Sense&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/cutting-your-side-projects-cloud-bill-a-checklist-that-doesnt-sacrifice-uptime-17kf"&gt;Cutting Your Side Project's Cloud Bill: A Checklist That Doesn't Sacrifice Uptime&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/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;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>cloud</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
