<?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: Jatin Jain Saraf</title>
    <description>The latest articles on DEV Community by Jatin Jain Saraf (@jatinjainsaraf).</description>
    <link>https://dev.to/jatinjainsaraf</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%2F3979918%2F540012a9-fdb8-46fc-b44f-9e33bf09c240.jpg</url>
      <title>DEV Community: Jatin Jain Saraf</title>
      <link>https://dev.to/jatinjainsaraf</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jatinjainsaraf"/>
    <language>en</language>
    <item>
      <title>The Three Layers of Failure Isolation: Timeouts, Circuit Breakers, and Load Shedding</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Mon, 03 Aug 2026 17:53:46 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/the-three-layers-of-failure-isolation-timeouts-circuit-breakers-and-load-shedding-nig</link>
      <guid>https://dev.to/jatinjainsaraf/the-three-layers-of-failure-isolation-timeouts-circuit-breakers-and-load-shedding-nig</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;500 requests a second are hitting a dependency that is completely dead. Every single one gets a clean timeout after 2 seconds, exactly as configured. And you are still down.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence is the whole problem with treating resilience as a single setting. A timeout did its job, nothing hung forever, and the aggregate is still an outage, because you're holding 1,000 concurrent doomed requests at once, and every one of them is load on a dependency that's trying to restart.&lt;/p&gt;

&lt;p&gt;There isn't one pattern that fixes this. There are three, stacked, each one picking up exactly where the last one's guarantee runs out. Get the order wrong, or skip one, and you don't get partial protection, you get a different failure mode wearing the same symptoms.&lt;/p&gt;

&lt;p&gt;This is that stack: timeouts and retries, circuit breakers and bulkheads, backpressure and load shedding. What each one actually bounds, why the one before it isn't enough, and the specific way each gets misconfigured in a way that looks fine until the day it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer One: A Timeout Doesn't Wait Patiently, It Fails Together
&lt;/h2&gt;

&lt;p&gt;Most client libraries default to no overall timeout. &lt;code&gt;fetch&lt;/code&gt; in Node has no total deadline unless you add one. Plenty of database drivers wait indefinitely. That default isn't neutral, it's a decision to couple your availability to your slowest dependency, and Little's Law explains exactly how much:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;concurrency = arrival rate × service time
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A dependency's p99 goes from 50ms to 30 seconds. Your arrival rate hasn't changed. Your concurrency just rose by a factor of 600, and every one of those in-flight requests is holding a socket, a pool connection, a request slot, memory. At 200 requests/second and a 30-second service time, you need 6,000 concurrent slots. You don't have 6,000.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;downstream slows → your concurrency climbs → pool exhausted → queue builds
                 → YOUR p99 rises for every endpoint, including ones that
                   never call that dependency → your own callers time out
                 → and they retry, adding load → you are now the outage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing failed. A dependency got slow, and the absence of a bound propagated it outward. A timeout is how you convert &lt;em&gt;someone else's&lt;/em&gt; latency problem into &lt;em&gt;your&lt;/em&gt; error rate, which sounds like a downgrade and isn't, because an error is bounded and recoverable, while unbounded latency spreads.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The queue at a single service desk, where one customer's transaction is taking forty minutes.&lt;/strong&gt; With no policy, the twenty people behind them wait forty minutes, the next twenty leave, and the shop's throughput collapses over one difficult case. With a policy, "five minutes per customer, then take a ticket and come back", one customer is inconvenienced and the queue keeps moving.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Only one of your four timeouts actually bounds anything
&lt;/h3&gt;

&lt;p&gt;A single number labelled "timeout" is usually not the number you think it is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Connect&lt;/strong&gt; bounds the TCP handshake. Catches a host that's down or unroutable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to first byte&lt;/strong&gt; bounds server thinking time. Catches a slow query or a saturated server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idle / socket&lt;/strong&gt; bounds the gap between bytes. Catches a stream that stalls mid-response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total / overall&lt;/strong&gt; bounds the whole operation, including retries. &lt;strong&gt;This is the only one that actually protects your resource usage.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A 2-second connect timeout and a 5-second read timeout don't add up to a guarantee, a response trickling one byte every 4 seconds never trips either one. Set the overall deadline, always, and treat the other three as diagnostics that let you fail earlier with a clearer reason why.&lt;/p&gt;

&lt;p&gt;Choose the number from the &lt;strong&gt;p99.9 of the successful response distribution&lt;/strong&gt;, not the average. Too short and you abandon requests that would have succeeded, converting them into retries, a load amplifier disguised as a safety measure. Too long and you hold resources through a failure you could have detected sooner.&lt;/p&gt;

&lt;p&gt;And here's the arithmetic that catches nearly everyone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Your caller's timeout:      5s
Your per-attempt timeout:   3s
Your retry policy:          3 attempts
Your worst-case total:      3 + backoff + 3 + backoff + 3 ≈ 10s

At t=5s your caller already gave up. Attempts 2 and 3 are work
for nobody, executed against a dependency that's already struggling.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A retry policy has to fit inside the overall budget: per-attempt timeout is &lt;code&gt;remaining_budget / max_attempts&lt;/code&gt;, not a number picked independently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Not every failure is safe to retry, and a status code isn't proof
&lt;/h3&gt;

&lt;p&gt;Connection refused, DNS failure, 503, 502, safe to retry, nothing happened yet. A 429, safe, and honour &lt;code&gt;Retry-After&lt;/code&gt;. A 500 or a timeout, &lt;strong&gt;safe only if the operation is idempotent&lt;/strong&gt;, because both are the canonical ambiguous failure: the request may have already succeeded server-side and you just never heard back. A 400, 422, 404, never retry; identical bytes fail identically.&lt;/p&gt;

&lt;p&gt;HTTP method semantics are a hint, not a guarantee. &lt;code&gt;GET&lt;/code&gt; and &lt;code&gt;PUT&lt;/code&gt; are &lt;em&gt;specified&lt;/em&gt; idempotent, and plenty of real handlers aren't, a &lt;code&gt;GET&lt;/code&gt; that increments a view counter, a &lt;code&gt;PUT&lt;/code&gt; that appends. Decide from what the operation does and whether it carries an idempotency key, not from the verb.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backoff needs jitter, and jitter isn't a refinement
&lt;/h3&gt;

&lt;p&gt;Fixed-interval retries fail for a specific reason: a thousand clients that failed at the same moment retry at the same moment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fixed 1s:      ████    ████    ████     ← full fleet, in phase, forever

Exponential:   ████      ████        ████    ← spaced out, still in phase

Exp + jitter:  ▁▃▂▁▄▂▃▁▂▄▁▃▂▄▁▂▃▁▄▂▃  ← smeared into a manageable trickle
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exponential backoff fixes the frequency of retries. It does nothing for synchronisation. Without jitter, a recovering dependency gets knocked over by the first aligned burst, which restarts the whole cycle.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Full jitter: sleep is uniform over [0, capped exponential]&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempt&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;const&lt;/span&gt; &lt;span class="nx"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&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="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// cap matters, uncapped, attempt 12 waits 9 hours&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;exp&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;Cap the exponential, bound the attempt count, and make the first retry near-immediate for a plain connection refusal, that failure costs the dependency nothing to answer again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Retry amplification: the incident that outlives its own cause
&lt;/h3&gt;

&lt;p&gt;Here's the part that turns a thirty-second blip into a two-hour outage.&lt;/p&gt;

&lt;p&gt;Retries multiply through layers. A request path where every layer retries three times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client ──3×──► gateway ──3×──► API ──3×──► service ──3×──► database
                                                  81 attempts at the bottom
                                                  for ONE user request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each layer is individually reasonable. Together they're an 81× amplifier, and it engages &lt;em&gt;exactly when the deepest component is failing&lt;/em&gt;, because that's what triggered the retries in the first place. A database at a 50% error rate, hit with three times its normal load, doesn't recover. It produces more errors. Which produce more retries. Which produce more errors.&lt;/p&gt;

&lt;p&gt;This is a &lt;strong&gt;metastable failure state&lt;/strong&gt;: one that sustains itself after its trigger is gone. A 30-second network blip causes a wave of retries. The retries push load above capacity. Being above capacity produces timeouts, which produce more retries. The network has been fine for an hour, and the system does not recover, because the load keeping it down is the load its own failure generated. Removing the original cause changes nothing. The only way out is reducing load: shedding traffic, opening circuit breakers, or manually pulling the service out of rotation until queues drain.&lt;/p&gt;

&lt;p&gt;Two rules follow from this. &lt;strong&gt;Retry at one layer, not at every layer&lt;/strong&gt;, pick the one closest to the business logic, the one that holds the idempotency key, and make every other layer pass failures straight through. If a service mesh retries for you, the application must not also retry, or you've silently rebuilt the multiplier.&lt;/p&gt;

&lt;p&gt;And bound it structurally with a &lt;strong&gt;retry budget&lt;/strong&gt;, this is the single most valuable thing in this entire layer, and the mechanism most systems lack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Allow retries only up to ~10% of successful request volume.&lt;/span&gt;
&lt;span class="c1"&gt;// Errors can spike without retry load spiking, that's the whole point.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;retryTokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tryConsume&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* attempt */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// token bucket, refilled by SUCCESSES&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a per-request retry count, a 100% error rate produces 3× or 81× load. With a 10% retry budget, that same 100% error rate produces &lt;strong&gt;1.1× load&lt;/strong&gt;, the system fails fast and cheap, and leaves the dependency enough headroom to actually recover.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; the trigger for these incidents is almost always mundane, a failover, a deploy, a brief partition. The &lt;em&gt;duration&lt;/em&gt; is set entirely by whether the system can shed the load its own retries created. A retry budget and a circuit breaker cost about an afternoon each to build, and they're the difference between a five-minute blip and a two-hour incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cancellation has to actually cancel
&lt;/h3&gt;

&lt;p&gt;A client-side timeout does not stop the server. This is the detail that surprises people the most, and it matters most at the database:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A Node query timeout abandons the &lt;em&gt;response&lt;/em&gt;. The Postgres backend keeps executing the query, holding the connection, the snapshot, and its locks.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So a client-side timeout on a slow query gives you the worst of both worlds, the client has moved on and may retry, doubling the work, while the server runs the original query to completion anyway. Enforcement has to happen server-side:&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;SET&lt;/span&gt; &lt;span class="n"&gt;statement_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;-- the actual bound&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'1s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                            &lt;span class="c1"&gt;-- don't queue behind a lock&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;idle_in_transaction_session_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'10s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;-- kill abandoned transactions&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Layer Two: A Breaker Stops Making the Calls At All
&lt;/h2&gt;

&lt;p&gt;Layer one bounds each call. It does not stop you from making a thousand doomed calls a second.&lt;/p&gt;

&lt;p&gt;Go back to that dependency that's genuinely down, 2-second timeout, 500 requests/second arriving:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;500 req/s × 2s each = 1,000 concurrent in-flight requests
                      ...all of which will fail
                      ...each holding a socket, a slot, and memory
                      ...and all of it is load on a dependency trying to restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timeout did its job. Each request failed in bounded time. The aggregate is still an outage, you're spending your entire concurrency budget discovering, five hundred times a second, something you already knew.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;circuit breaker&lt;/strong&gt; is the observation that after enough failures, you can just stop asking. It converts a 2-second failing call into a sub-millisecond local rejection, and that does three things at once: it frees your resources instantly, it removes load from the dependency, giving it room to actually recover, which is the exit from the metastable state above, and it fails fast enough that a fallback becomes viable. A 2-second wait before serving a cached value is a bad experience. A 1-millisecond rejection followed by a cache read is a fine one.&lt;/p&gt;

&lt;p&gt;That second point is easy to undervalue and is often the decisive one. A dependency at 100% error rate does not recover while it's still receiving full traffic plus retries. A breaker is how the traffic actually stops, without a human doing it by hand.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The electrical breaker the pattern is named after&lt;/strong&gt; doesn't protect the appliance that shorted, that appliance is already broken. It protects the rest of the house, by isolating the one circuit so the wiring doesn't overheat and every other room keeps its lights on. And it needs a delay before reclosing, because closing it immediately onto an unfixed short achieves nothing but another trip.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The four ways a breaker gets misconfigured
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Trigger on a failure rate over a rolling window, not consecutive failures.&lt;/strong&gt; A consecutive-failure counter fails in both directions, on a low-traffic endpoint, five consecutive failures might span twenty minutes and open a breaker for a problem long since resolved, and on a dependency where half of calls succeed, a consecutive counter never reaches five at all. Use a rate with a minimum-volume guard, so a single failure after a quiet period doesn't read as a "100% failure rate over a sample of one":&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&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;// last 10 seconds&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;failureRate&lt;/span&gt; &lt;span class="o"&gt;&amp;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="nf"&gt;open&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;A &lt;code&gt;4xx&lt;/code&gt; is not a failure.&lt;/strong&gt; This is the misconfiguration that causes the most damage in practice. A 400, 404, or 422 means the dependency is healthy and rejected your request, bad input, a missing resource, failed validation. Count those as breaker failures and a single client bug, a URL scanner, or one malformed integration takes down a perfectly healthy dependency for every other caller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Threshold on slow calls too, not just errors.&lt;/strong&gt; A dependency at 100% success and 8 seconds per call is doing just as much damage as one that's down, arguably more, because nothing about it looks like an error. A slow-call-rate threshold ("if 60% of calls exceed 1 second, open") is the check most implementations skip entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Half-open must admit a trickle, not a flood.&lt;/strong&gt; A cooldown that expires and lets the full 500 requests/second back through at once re-hammers a recovering dependency and reopens the breaker instantly. Admit one to three concurrent probes, require a few successes before closing, and ideally ramp: half-open at 5% of traffic, then 25%, then closed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bulkheads: stop one dependency from starving everything else
&lt;/h3&gt;

&lt;p&gt;A ship's hull is divided into watertight compartments so one breach floods a single compartment, not the vessel. The system equivalent: partition the resource requests compete for, so one dependency's slowness can't consume all of it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Without: 200 concurrent slots, shared.
  The recommendations service slows to 8s. Requests to it accumulate.
  Within seconds all 200 slots hold recommendation calls.
  Checkout, which never calls recommendations, gets no slot. Total outage.

With:  recommendations capped at 20 concurrent.
  Slot 21 onwards is rejected immediately → the fallback runs.
  Checkout's 180 slots are untouched. Recommendations are degraded. Nothing else is.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Node there's no thread pool to partition, which leads some people to conclude bulkheads don't apply. They do, the shared resource is event-loop time, the socket pool, and memory held by pending promises. The mechanism is a semaphore that &lt;strong&gt;rejects when full&lt;/strong&gt;, not one that queues without bound:&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="nx"&gt;pLimit&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;p-limit&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;limits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;recommendations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;pLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;     &lt;span class="c1"&gt;// optional feature: small allowance&lt;/span&gt;
  &lt;span class="na"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="nf"&gt;pLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;     &lt;span class="c1"&gt;// critical path: generous&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;callRecommendations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recommendations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pendingCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BulkheadFull&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;limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recommendations&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;fetchRecs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A semaphore that queues indefinitely isn't a bulkhead, it's a delay, and the memory held by that queue is exactly the resource you were trying to protect. Size it from Little's Law: a dependency with a 50ms p99 sustaining 200 requests/second needs 10 concurrent slots, not 200. And the single highest-value bulkhead most teams skip is separate connection pools per workload, web traffic and a reporting job should never draw from the same Postgres pool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Degradation is design work, and it hides the failure that follows
&lt;/h3&gt;

&lt;p&gt;Breakers and bulkheads decide &lt;em&gt;when&lt;/em&gt; to stop calling something. Degradation decides &lt;em&gt;what the user gets instead&lt;/em&gt;, and it's the part that can't be configured away, it needs a decision per dependency, often a product decision rather than an engineering one.&lt;/p&gt;

&lt;p&gt;The rule that catches the most bugs: &lt;strong&gt;the fallback must not depend on what failed.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BROKEN: the fallback path hits the same overloaded database.&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cache&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="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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expensiveQuery&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// ← 100% of traffic now goes here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redis going down doesn't just remove the cache, it redirects the entire cache hit rate onto the database. A cache at a 95% hit ratio failing means the database sees 20× its normal read load, and the fallback has just converted a cache outage into a database outage. The correct version serves the degraded path behind a bulkhead and a request-coalescing lock, and sheds the excess rather than manufacturing a second failure.&lt;/p&gt;

&lt;p&gt;And here's the trap specific to this layer, worth sitting with: &lt;strong&gt;degradation removes failure from your error rate.&lt;/strong&gt; Once it's working, a failing dependency produces no errors. Requests succeed. Latency might even improve, because a fast fallback replaced a slow call. Your dashboard is flat. Your alerts are quiet.&lt;/p&gt;

&lt;p&gt;So a system can serve the generic homepage instead of the personalised one for three weeks, because a breaker opened after a deploy changed a hostname and never closed, and nobody notices, because nothing is red. The first report comes from a product manager asking why engagement dropped. The fix is instrumenting the thing degradation hides: breaker state as a metric with an alert on "open for longer than N minutes," and a degraded-serve rate tracked as its own SLI, right next to your error rate, because it &lt;em&gt;is&lt;/em&gt; the error rate you decided not to show users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer Three: When Nothing Is Broken and You're Still Down
&lt;/h2&gt;

&lt;p&gt;Layers one and two both answer the same question: &lt;em&gt;a dependency I call is broken, what do I do?&lt;/em&gt; This layer answers a different one entirely.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Everything downstream is healthy. Every query is fast. And 9,000 requests a second are arriving at a service that can serve 6,000.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No breaker opens, because nothing is failing. No bulkhead helps, because no single dependency is being monopolized, the resource is being consumed by legitimate, healthy work. Queues just grow, latency climbs uniformly across everything, and eventually it all times out at once.&lt;/p&gt;

&lt;p&gt;The counterintuitive fact underneath this: capacity isn't a ceiling you bump into. It's a knee, after which things get &lt;em&gt;worse&lt;/em&gt;, not just full. &lt;strong&gt;Throughput&lt;/strong&gt; is requests completed per second. &lt;strong&gt;Goodput&lt;/strong&gt; is requests completed per second that anybody still wanted, inside the caller's deadline. Past the knee, throughput can look almost flat while goodput falls off a cliff, because the work still being completed is work whose caller gave up on seconds ago. A service can sit at 100% CPU, complete 6,000 requests a second, and deliver a genuinely useful 400, and its throughput graph will look perfectly fine the entire time.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A kitchen that takes every order the floor brings.&lt;/strong&gt; At 30 covers it's fine. At 90, tickets pile up, and by the time each dish is plated the table has left. The kitchen is working flat out, food is going out at the maximum rate the stoves allow, and nobody is eating it. The fix isn't a bigger ticket rail, it's the maître d' at the door saying "we're full, forty-five minute wait." That refusal is the only intervention that gets the guests who &lt;em&gt;are&lt;/em&gt; seated actually fed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  An unbounded queue is not a buffer
&lt;/h3&gt;

&lt;p&gt;"Add a queue so we can absorb the spike" is correct advice for a transient burst and catastrophic advice for sustained overload. The arithmetic is Little's Law again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;queue depth 50,000, throughput 500/s → wait time = 100 seconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of those 50,000 items gets processed eventually. Every single one gets processed after its caller has already timed out. The system does the full amount of work and delivers none of the value, and the memory holding that queue is itself now a failure mode.&lt;/p&gt;

&lt;p&gt;Worse, the queue changes the &lt;em&gt;shape&lt;/em&gt; of the failure into a less useful one. Without it, request 6,001 gets an immediate &lt;code&gt;503&lt;/code&gt;, the client knows right away, can retry with backoff, can fall back, can tell the user something. With an unbounded queue, request 6,001 gets accepted and times out 30 seconds later, after the client has waited and held its own resources for nothing, learned nothing sooner, and now retries, adding a &lt;em&gt;second&lt;/em&gt; item to the queue for work you'd already started.&lt;/p&gt;

&lt;p&gt;So: every queue is bounded, and the bound comes from the latency target you actually care about.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;max_depth = target_latency × service_rate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Serving at 500/s with a 2-second latency target means a queue of 1,000 items, not one more. Beyond that, reject. This applies to your HTTP accept queue, your connection-pool wait queue, your job queue, every in-process channel. An unbounded queue anywhere in the request path is exactly where the latency will accumulate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backpressure where you can, shedding where you can't
&lt;/h3&gt;

&lt;p&gt;Backpressure means the consumer tells the producer to slow down, and the producer &lt;em&gt;can&lt;/em&gt;. It's strictly better wherever it's available, because no work gets discarded, the rate just matches. TCP flow control is the original version of this idea; HTTP/2 flow-control windows, reactive streams, bounded channels where the producer blocks, and consumer pause/resume on a Kafka worker are all re-implementations of it. In Node, &lt;code&gt;writable.write()&lt;/code&gt; returning &lt;code&gt;false&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; backpressure, ignoring that return value and writing anyway is the standard way to leak memory in a pipeline.&lt;/p&gt;

&lt;p&gt;Backpressure works inside a closed system, your own pipeline, code you control. It fails completely at an open boundary: you can't tell a million browsers, a partner's integration, or a mobile app fleet to send fewer requests. There's no window to shrink. At that boundary, the only option left is &lt;strong&gt;load shedding&lt;/strong&gt;, refuse work immediately and cheaply, so the work you do accept actually completes. Shedding isn't a failure of design. It's the design. The choice was never "shed or serve everyone", it's "shed deliberately, or let the overload choose for you by timing everything out instead."&lt;/p&gt;

&lt;h3&gt;
  
  
  Shed on the signal that tells the truth earliest
&lt;/h3&gt;

&lt;p&gt;Not every signal is equally honest, and the ranking matters:&lt;/p&gt;

&lt;p&gt;Queue wait time is the best signal, it &lt;em&gt;is&lt;/em&gt; the latency you're about to violate, and it leads everything else. Concurrency in flight versus your limit is next, direct and cheap. Queue depth is good but needs a service rate to interpret. Event-loop delay in Node measures the actual contended resource. CPU utilisation is mediocre, saturation isn't linear in CPU, and I/O-bound work can show low CPU while queueing badly. Latency p99 lags, by the time it moves, the queue is already deep. And error rate is the worst signal of all: it's the outcome you were trying to prevent in the first place, arriving last.&lt;/p&gt;

&lt;p&gt;For Node specifically, event-loop delay is an excellent, cheap, local signal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;monitorEventLoopDelay&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:perf_hooks&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;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;monitorEventLoopDelay&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;resolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// Shed when the loop is persistently behind: the process cannot keep up, full stop.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;overloaded&lt;/span&gt; &lt;span class="o"&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;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mean&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// ms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a shed response is only cheap if it happens early, reject before authentication, before deserialising a large body, before touching the database. A &lt;code&gt;503&lt;/code&gt; that costs as much to produce as a &lt;code&gt;200&lt;/code&gt; protects nothing at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Order Matters, Not Just the Presence
&lt;/h2&gt;

&lt;p&gt;Put together, the three layers cover three genuinely different failures, and none of them substitutes for another:&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;timeout&lt;/strong&gt; bounds one call to a dependency that's slow. It does nothing about the &lt;em&gt;volume&lt;/em&gt; of calls you keep making to one that's dead, that's what a &lt;strong&gt;circuit breaker&lt;/strong&gt; stops. A breaker does nothing about legitimate, healthy traffic simply exceeding your own capacity, that's &lt;strong&gt;backpressure and shedding&lt;/strong&gt;. And underneath all three sits the retry logic that, misconfigured, turns any one of these into a self-sustaining outage regardless of how well the other two are built.&lt;/p&gt;

&lt;p&gt;This is also, not coincidentally, the fuller version of a claim made in passing in an earlier piece on &lt;a href="https://insight.jatinjainsaraf.com/connection-pooling-in-the-serverless-era-five-failure-modes" rel="noopener noreferrer"&gt;connection pooling in serverless environments&lt;/a&gt;: that platform retry policies, re-invoking every failed serverless request during a connection exhaustion event, are "one of the few failure modes where client retries are strictly harmful." That's retry amplification, in exactly the shape described above, a thousand concurrent invocations failing at once, each one retried by the platform, adding load to a connection table whose entire problem was already too many clients. A retry budget at the right layer, or a breaker that stops the calls outright, is the actual fix; raising &lt;code&gt;max_connections&lt;/code&gt; again is not.&lt;/p&gt;

&lt;p&gt;None of these three layers is optional once you're running anything with a real dependency graph. But they answer different questions, they fail in different ways when misconfigured, and the order in which you reach for them, bound the call, stop the calls, then shed what you can't backpressure, is the order that actually holds under load.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sourced from the &lt;a href="https://academy.jatinjainsaraf.com/system-design-in-depth" rel="noopener noreferrer"&gt;System Design In-Depth&lt;/a&gt; course, &lt;a href="https://academy.jatinjainsaraf.com/system-design-in-depth/timeouts-retries-backoff" rel="noopener noreferrer"&gt;Timeouts, Retries, and Backoff&lt;/a&gt;, &lt;a href="https://academy.jatinjainsaraf.com/system-design-in-depth/circuit-breakers-bulkheads-degradation" rel="noopener noreferrer"&gt;Circuit Breakers, Bulkheads, and Graceful Degradation&lt;/a&gt;, and &lt;a href="https://academy.jatinjainsaraf.com/system-design-in-depth/backpressure-and-load-shedding" rel="noopener noreferrer"&gt;Backpressure and Load Shedding&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>resilienceengineerin</category>
      <category>distributedsystems</category>
      <category>backenddevelopment</category>
    </item>
    <item>
      <title>Connection Pooling in the Serverless Era: Five Failure Modes</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Sun, 02 Aug 2026 08:23:44 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/connection-pooling-in-the-serverless-era-five-failure-modes-20hh</link>
      <guid>https://dev.to/jatinjainsaraf/connection-pooling-in-the-serverless-era-five-failure-modes-20hh</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Your database CPU is at 20%. Your slowest query is 12ms. Your slow-query log is empty. And your p99 is three seconds. This is what a connection pool failure looks like — and it never looks like a database problem.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every engineer learns the same sentence about connection pooling: "reuse connections instead of opening a new one per request." It's true, it's useful, and it's where most people stop.&lt;/p&gt;

&lt;p&gt;Then you deploy to serverless. Or you turn on autoscaling. Or someone adds a metrics exporter. And you discover that connection pooling isn't a performance optimisation you bolt on — it's a shared, global, hard-capped budget that half your infrastructure is spending without telling anyone.&lt;/p&gt;

&lt;p&gt;This article is about the failure modes. Not "what is a connection pool," but the five specific ways pooling breaks in modern deployments, why each one disguises itself as something else, and what the fix actually costs you.&lt;/p&gt;




&lt;h2&gt;
  
  
  First: A Postgres Connection Is Not a Socket
&lt;/h2&gt;

&lt;p&gt;Engineers price a database connection like an HTTP connection — a file descriptor, some buffers, a few kilobytes. Cheap. Open a thousand of them.&lt;/p&gt;

&lt;p&gt;In PostgreSQL, a connection is a &lt;strong&gt;forked operating system process&lt;/strong&gt;. Per connection, you get:&lt;/p&gt;

&lt;p&gt;An OS process with its own page tables and scheduler entry, plus a few megabytes of private memory that never becomes shared. This is why connecting to Postgres costs orders of magnitude more than connecting to Redis, and why "one connection per request" is a design error rather than a style preference.&lt;/p&gt;

&lt;p&gt;The right to allocate &lt;code&gt;work_mem&lt;/code&gt; — and not once per connection, but &lt;strong&gt;per sort or hash node in the running query&lt;/strong&gt;. One query with three hash joins holds three multiples of it. This is how databases get OOM-killed while every dashboard looks calm.&lt;/p&gt;

&lt;p&gt;A snapshot, if the connection is inside a transaction, which joins the vacuum horizon. That's how a single forgotten &lt;code&gt;idle in transaction&lt;/code&gt; connection blocks dead-tuple cleanup across the entire database.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;max_connections = 100&lt;/code&gt; isn't an arbitrary cap the Postgres developers picked to annoy you. It's &lt;strong&gt;a memory and scheduler budget expressed as a count&lt;/strong&gt;. Raising it to 2,000 doesn't buy you 2,000 connections' worth of throughput — it buys you 2,000 processes contending for the same cores and the same &lt;code&gt;shared_buffers&lt;/code&gt;, trading a polite refusal of the 101st client for death by memory pressure.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The analogy worth keeping:&lt;/strong&gt; your database is a restaurant with a fixed number of tables and a fixed number of cooks. &lt;code&gt;max_connections&lt;/code&gt; is the tables. On a busy night the tempting move is to cram in more tables — but the cooks didn't multiply, so every dish arrives late and the kitchen falls behind on all of it at once. The restaurant that keeps ten tables and queues everyone else at the door serves &lt;em&gt;more&lt;/em&gt; diners per hour.&lt;/p&gt;

&lt;p&gt;That queue at the door is your connection pool's wait queue. It is the cheapest place in your entire system for a request to wait, and the last place anyone thinks to measure it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Failure Mode 1: The Only Number That Matters Is &lt;code&gt;N × Pool Size&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;A pool is configured &lt;strong&gt;per process&lt;/strong&gt;. The limit is &lt;strong&gt;global&lt;/strong&gt;. Nearly every exhaustion incident lives in that gap.&lt;/p&gt;

&lt;p&gt;Here's a realistic accounting for a modest production system running against &lt;code&gt;max_connections = 100&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Consumer&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;Connections&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API instances × pool&lt;/td&gt;
&lt;td&gt;12 × 20&lt;/td&gt;
&lt;td&gt;240&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker instances × pool&lt;/td&gt;
&lt;td&gt;4 × 10&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cron / scheduled jobs&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration job during deploy&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1–5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metrics exporter&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2–10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An analyst's &lt;code&gt;psql&lt;/code&gt; session&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;superuser_reserved_connections&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;3 reserved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total demand&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~290 against 97 usable&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things make this vicious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's a deploy-time cliff, not a load-time one.&lt;/strong&gt; A rolling deploy briefly runs old and new instances side by side, doubling that top row. Which is why these incidents correlate with deploys rather than with traffic, and why the postmortem keeps looking at the wrong graph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The bottom rows are invisible.&lt;/strong&gt; Nobody counts the metrics exporter. Nobody counts the migration container. Those are exactly what tip you over.&lt;/p&gt;

&lt;p&gt;The rule, stated properly: &lt;strong&gt;allocate &lt;code&gt;max_connections&lt;/code&gt; as a budget across all consumers, then derive per-instance pool size by division.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;pool_size_per_instance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_connections&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;max_instances&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;safety_buffer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the cost of that rule, stated honestly: pool size now depends on replica count. It has to be computed against your &lt;strong&gt;autoscaler's ceiling&lt;/strong&gt;, not your current instance count — which means you deliberately run a pool smaller than any single instance could use at peak.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; your autoscaler's max-replica setting &lt;em&gt;is&lt;/em&gt; a database configuration setting. If &lt;code&gt;max_replicas × pool_size&lt;/code&gt; exceeds &lt;code&gt;max_connections&lt;/code&gt;, you haven't got a risk. You've configured an outage and scheduled it for the next traffic spike.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure Mode 2: Serverless Turns Concurrency Into Connections
&lt;/h2&gt;

&lt;p&gt;A function runtime has no shared pool because it has no shared process. Each concurrent invocation is an isolated environment whose pool has a maximum useful size of one — it serves exactly one request.&lt;/p&gt;

&lt;p&gt;Warm containers reuse a connection across invocations, which helps, and which is precisely why this problem is intermittent and maddening to reproduce. But the scaling unit is &lt;strong&gt;concurrency&lt;/strong&gt;, and concurrency is the one thing you don't control.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1,000 concurrent invocations × 1 connection each = 1,000 connections
max_connections = 100, minus 3 reserved, minus everything in the table above
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roughly 90 invocations get a connection. The rest get &lt;code&gt;FATAL: sorry, too many clients already&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's a specific version of this that catches teams who &lt;em&gt;did&lt;/em&gt; read the tutorial:&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;// Looks correct. Is correct — in a long-running Node server.&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&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;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM transactions LIMIT 10&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;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&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;In a long-running process, &lt;code&gt;const pool&lt;/code&gt; is created once and reused across every request. Correct. In a serverless function, the module is re-imported on each cold start — so &lt;code&gt;max: 10&lt;/code&gt; isn't a ceiling of 10 connections, it's a ceiling of &lt;strong&gt;10 per concurrent environment&lt;/strong&gt;. A hundred concurrent invocations makes it 1,000.&lt;/p&gt;

&lt;p&gt;The global singleton pattern helps at the margins, because it survives warm starts within a container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/db.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pg&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;globalForPg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;global&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;global&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pgPool&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;Pool&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;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;globalForPg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pgPool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;globalForPg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pgPool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max&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="c1"&gt;// small per instance — the pooler owns the total&lt;/span&gt;
    &lt;span class="na"&gt;idleTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;connectionTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_000&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;globalForPg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pgPool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But be clear about what this is: &lt;strong&gt;damage control, not a fix.&lt;/strong&gt; It caps each environment at 2 connections instead of 10. It does not stop the platform from giving you 500 environments. On Vercel Functions and equivalents, the singleton pattern alone is insufficient — every team shipping a direct Postgres connection plus a singleton is running on borrowed time, and hasn't hit the limit only because they haven't hit enough concurrent traffic yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  The blast radius is the real story
&lt;/h3&gt;

&lt;p&gt;What makes this an architecture problem rather than a tuning problem is that &lt;strong&gt;the failure does not land on the traffic that caused it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The connection table is global. When it's full, it's full for everybody:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The internal admin panel stops loading — and its on-call isn't yours.&lt;/li&gt;
&lt;li&gt;The payouts worker's queue backs up silently.&lt;/li&gt;
&lt;li&gt;The metrics exporter fails, so your dashboards go blank &lt;em&gt;during&lt;/em&gt; the incident.&lt;/li&gt;
&lt;li&gt;An engineer opens &lt;code&gt;psql&lt;/code&gt; to investigate and gets refused.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the platform's retry policy re-invokes every failed request, adding load to a resource whose entire problem is too many clients. This is one of the few failure modes where &lt;strong&gt;client retries are strictly harmful&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; connection exhaustion is a tenancy problem as much as a capacity one. If one spiky autoscaled workload can consume the budget of your payments worker, you've coupled two services through a resource neither of them monitors. The cheap mitigation is a per-role cap — &lt;code&gt;ALTER ROLE app_web CONNECTION LIMIT 40&lt;/code&gt; — which converts a shared outage into a contained one, at the cost of one workload hitting its ceiling while slots sit idle elsewhere.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure Mode 3: Raising the Pool Makes It Worse
&lt;/h2&gt;

&lt;p&gt;This is the counterintuitive one, and it's the standard incident response.&lt;/p&gt;

&lt;p&gt;Requests are timing out waiting for connections. An engineer raises the per-instance pool from 20 to 100 across 10 instances, restarts, and throughput drops further while p99 gets worse.&lt;/p&gt;

&lt;p&gt;Little's Law explains why. &lt;code&gt;L = λW&lt;/code&gt; — the number of connections you need busy at once is arrival rate times hold time. Take 400 req/s where each request runs one 5ms query, then run it again after a bad plan pushes that query to 200ms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Healthy:  L = 400/s × 0.005s = 2 connections busy on average
          ρ = (400 × 0.005) / 20 = 0.1   → waits negligible

Degraded: L = 400/s × 0.200s = 80 connections wanted, against a pool of 20
          ρ = (400 × 0.200) / 20 = 4.0   → demand exceeds capacity, queue grows unbounded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two connections is the honest answer at healthy load. Intuition wants pool size to track concurrent &lt;em&gt;requests&lt;/em&gt;; Little's Law says it tracks concurrent requests &lt;strong&gt;× the fraction of their life spent inside the database&lt;/strong&gt;, which is usually small. That gap is why correctly-sized pools look absurdly low to people who haven't done the arithmetic.&lt;/p&gt;

&lt;p&gt;So why doesn't a bigger pool help in the degraded row? Because service time &lt;code&gt;S&lt;/code&gt; isn't a constant. It depends on how many queries are executing concurrently against fixed cores and a fixed disk. Past hardware saturation, extra in-flight queries add no throughput — they divide the same throughput into slower pieces. Then second-order costs push throughput actively &lt;em&gt;down&lt;/em&gt;: context switching between hundreds of runnable backends, contention on buffer-mapping and lock-manager partitions, &lt;code&gt;work_mem&lt;/code&gt; allocations evicting your working set from cache, and more concurrent snapshots holding back the vacuum horizon.&lt;/p&gt;

&lt;p&gt;Hence the principle worth memorising: &lt;strong&gt;queueing outside the database is nearly free; queueing inside it is expensive.&lt;/strong&gt; A request in a pool's FIFO costs a promise and a timer. A request inside the database costs a process, several megabytes, a snapshot, and a share of every lock partition it touches.&lt;/p&gt;

&lt;p&gt;The community starting point, popularised by HikariCP, is &lt;code&gt;connections ≈ (2 × core_count) + effective_spindles&lt;/code&gt;. Treat it as a hypothesis to load-test, &lt;strong&gt;not a law&lt;/strong&gt; — &lt;code&gt;effective_spindles&lt;/code&gt; is a rotational-disk-era proxy for storage concurrency, and on NVMe at 500K+ random IOPS it means something quite different from its name. Note also that it lands in the low tens &lt;em&gt;for the whole database&lt;/em&gt;, not per instance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; during pool exhaustion, the right move is almost never "raise the pool." It's to find what raised hold time — a slow query, a lock wait, an external call inside a transaction — because &lt;code&gt;L = λW&lt;/code&gt; makes pool demand linear in &lt;code&gt;W&lt;/code&gt;, and &lt;code&gt;W&lt;/code&gt; is the term you can usually cut by an order of magnitude. Raising &lt;code&gt;C&lt;/code&gt; to match a broken &lt;code&gt;W&lt;/code&gt; just relocates the collapse into the database, where it costs more and hides better.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure Mode 4: Transaction Pooling Silently Eats Session State
&lt;/h2&gt;

&lt;p&gt;The structural fix for serverless is a transaction-mode pooler — PgBouncer, RDS Proxy, Supavisor, Prisma Accelerate. A lightweight process holds a few real backends and multiplexes many clients onto them, lending a backend only for the duration of a transaction. Ten thousand clients, twenty backends.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it costs is session state&lt;/strong&gt;, because the backend you get is not the one you had last time. And the way you find out is a production error that says nothing about pooling.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Why it breaks under transaction pooling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server-side prepared statements&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PREPARE&lt;/code&gt; lives on one backend; your next statement may land on another. PgBouncer 1.21+ can track these — verify your version rather than assume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session &lt;code&gt;SET&lt;/code&gt; variables&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SET search_path&lt;/code&gt; or &lt;code&gt;SET timezone&lt;/code&gt; applies to a backend you're about to lose. &lt;code&gt;SET LOCAL&lt;/code&gt; inside a transaction is safe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;LISTEN&lt;/code&gt; / &lt;code&gt;NOTIFY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Needs a persistent session to receive on. Silently receives nothing — no error, just missing events&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session-level advisory locks&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_advisory_lock()&lt;/code&gt; is held by a session about to be lent elsewhere, and can never be released by its owner. Use &lt;code&gt;pg_advisory_xact_lock()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Temp tables, &lt;code&gt;WITH HOLD&lt;/code&gt; cursors&lt;/td&gt;
&lt;td&gt;Session-scoped objects. Gone at transaction end&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The failure signature is worth committing to memory, because nothing in it mentions pooling: &lt;strong&gt;the ORM works perfectly locally against a direct connection, and throws &lt;code&gt;prepared statement "s0" does not exist&lt;/code&gt; in production.&lt;/strong&gt; Or &lt;code&gt;relation "work_items" does not exist&lt;/code&gt; halfway through a request. Or — worst of all — no error, just timezone drift producing quietly wrong date arithmetic, because a session &lt;code&gt;SET&lt;/code&gt; didn't survive.&lt;/p&gt;

&lt;p&gt;For Prisma specifically, the two-connection-string setup is non-negotiable, because the migration engine uses &lt;strong&gt;session-level advisory locks&lt;/strong&gt; and will hang indefinitely through a transaction-mode pooler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DATABASE_URL="postgresql://user:pass@pooler-host:6432/mydb?pgbouncer=true"
DIRECT_URL="postgresql://user:pass@postgres-host:5432/mydb"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")   // pooler, for app queries
  directUrl = env("DIRECT_URL")     // direct, for migrations
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The general pattern, whatever your stack: &lt;strong&gt;two endpoints against one database.&lt;/strong&gt; A transaction-mode port for application traffic, and a session-mode or direct connection for migrations, &lt;code&gt;LISTEN&lt;/code&gt;-based workers, and human debugging. The cost is a second connection string to configure, get wrong once, and document.&lt;/p&gt;

&lt;p&gt;One more thing worth saying plainly: session mode is the compatibility escape hatch, &lt;strong&gt;not&lt;/strong&gt; a fix for exhaustion. It holds a backend for the client's entire connection, giving roughly 1:1 reuse — all of a proxy's operational cost with none of the multiplexing benefit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Failure Mode 5: Holding a Connection Across a Call You Don't Control
&lt;/h2&gt;

&lt;p&gt;This one passes code review every single time.&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;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BEGIN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UPDATE orders SET status = $1 WHERE id = $2&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;charging&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="c1"&gt;// Three seconds of somebody else's p99 — with a backend, a snapshot,&lt;/span&gt;
&lt;span class="c1"&gt;// and a row lock all held open, executing nothing.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charge&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;usd&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UPDATE orders SET charge_id = $1 WHERE id = $2&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;charge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;COMMIT&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;Run &lt;code&gt;L = λW&lt;/code&gt; on it. At 50 req/s with a 3-second vendor call, you need 150 connections held to do essentially no database work. Your pool is 20.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your pool size is now a function of a vendor's p99.&lt;/strong&gt; When their latency doubles, every unrelated endpoint on that instance goes down with it.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;statement_timeout&lt;/code&gt; will not save you — this is the genuinely useful part. No statement is running. The backend is &lt;code&gt;idle in transaction&lt;/code&gt;, holding a snapshot that blocks vacuum database-wide and row locks that stall other writers, while executing nothing at all. The setting that actually fires is &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt;, which every application role should have, and which still only acts &lt;em&gt;after&lt;/em&gt; the damage, aborting mid-payment.&lt;/p&gt;

&lt;p&gt;The fix is structural, not configurational: commit an intent carrying an idempotency key, make the external call holding nothing, then commit the outcome in a second short transaction.&lt;/p&gt;

&lt;p&gt;The cost, named honestly: one atomic operation became two, so a crash between them leaves an order stuck in &lt;code&gt;charging&lt;/code&gt;. That needs a reconciliation job that finds stale intents and asks the provider what happened — which is exactly why the idempotency key is written in the &lt;em&gt;first&lt;/em&gt; transaction rather than generated at call time. The trade is real: a recoverable inconsistency in exchange for not tying your pool to someone else's uptime.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Metric Nobody Graphs
&lt;/h2&gt;

&lt;p&gt;Every failure mode above shares a diagnostic signature, and it's why these incidents burn hours.&lt;/p&gt;

&lt;p&gt;When the pool saturates, &lt;strong&gt;latency accumulates before any query runs&lt;/strong&gt; — so every tool you'd reach for measures the wrong interval:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;pg_stat_statements&lt;/code&gt; reports execution time. Your queries look fine at 8ms.&lt;/li&gt;
&lt;li&gt;Slow-query logs are silent. Nothing ran slowly.&lt;/li&gt;
&lt;li&gt;Your APM's database span starts once the driver &lt;em&gt;already holds&lt;/em&gt; a connection.&lt;/li&gt;
&lt;li&gt;Database CPU is low, which reads as "the database is healthy" and sends the investigation into application code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Meanwhile p99 is 3 seconds, of which 2.99 were spent inside &lt;code&gt;pool.connect()&lt;/code&gt; — an interval on no component's dashboard, because the app thinks it's database time and the database has never heard of the request.&lt;/p&gt;

&lt;p&gt;So instrument the acquisition yourself:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Pool&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;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&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;client&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;pool&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="c1"&gt;// The number that holds your p99 during saturation. Histogram it. Alert on p99.&lt;/span&gt;
  &lt;span class="nx"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;histogram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;db.pool.wait_ms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;performance&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="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also worth having permanently: &lt;strong&gt;waiting count&lt;/strong&gt; (&lt;code&gt;pool.waitingCount&lt;/code&gt;) for queue depth, &lt;strong&gt;in-use vs idle&lt;/strong&gt; (&lt;code&gt;totalCount&lt;/code&gt;, &lt;code&gt;idleCount&lt;/code&gt;) which gives you utilisation, and &lt;strong&gt;acquisition timeouts per minute&lt;/strong&gt;. Set &lt;code&gt;connectionTimeoutMillis&lt;/code&gt; — unset means unbounded queueing, which is a queue with no admission control.&lt;/p&gt;

&lt;p&gt;On the database side, the first query of any connection incident:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;state&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;pg_stat_activity&lt;/span&gt; &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A large &lt;code&gt;idle in transaction&lt;/code&gt; count is Failure Mode 5, live in production.&lt;/p&gt;

&lt;p&gt;And if you're running PgBouncer, the single most important view:&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;SHOW&lt;/span&gt; &lt;span class="n"&gt;POOLS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- cl_waiting: clients waiting for a server connection → THIS SHOULD BE 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Alert thresholds worth setting today:&lt;/strong&gt; warning at 70% of &lt;code&gt;max_connections&lt;/code&gt;, critical at 85% (you're roughly 90 seconds from user-visible errors), page immediately on more than 10 connections &lt;code&gt;idle in transaction&lt;/code&gt;, and alert on sustained &lt;code&gt;cl_waiting &amp;gt; 0&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fixes, and What Each One Actually Costs
&lt;/h2&gt;

&lt;p&gt;There is no free option. Pick the bill you'd rather pay.&lt;/p&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 buys&lt;/th&gt;
&lt;th&gt;What it costs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Transaction-mode pooler&lt;/strong&gt; (PgBouncer, RDS Proxy, Supavisor)&lt;/td&gt;
&lt;td&gt;Ten thousand clients onto twenty backends. The right answer for serverless&lt;/td&gt;
&lt;td&gt;Session state — prepared statements, session &lt;code&gt;SET&lt;/code&gt;, &lt;code&gt;LISTEN&lt;/code&gt;/&lt;code&gt;NOTIFY&lt;/code&gt;, session advisory locks, temp tables. Plus a network hop (~0.5–2ms) and a new component in the request path that can fail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;SQL over HTTP&lt;/strong&gt; (Neon serverless driver, Supabase client)&lt;/td&gt;
&lt;td&gt;Nothing persistent, so nothing to exhaust. Works in Edge Runtime, where TCP doesn't exist at all&lt;/td&gt;
&lt;td&gt;The interactive transaction. Read-decide-write has nowhere to live, so every &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; needs rethinking. Plus ~10–30ms per-query HTTP overhead and a vendor-specific driver&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Managed pooler + cache&lt;/strong&gt; (Prisma Accelerate)&lt;/td&gt;
&lt;td&gt;Pooling plus per-query TTL/SWR caching, no infrastructure to operate&lt;/td&gt;
&lt;td&gt;Vendor dependency — your database connectivity now depends on their uptime even when your Postgres is healthy. Plus a hop, plus pricing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Keep the pool in a long-running process&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Puts the pool where a pool can live; functions call it over HTTP&lt;/td&gt;
&lt;td&gt;The thing you were trying to delete. You now operate a server with deploys, health checks, and a scaling policy — and the bottleneck relocates to &lt;em&gt;that&lt;/em&gt; tier's concurrency limit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Mapping that to real deployments:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Infrastructure&lt;/th&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vercel / Netlify Functions&lt;/td&gt;
&lt;td&gt;HTTP driver or managed pooler. &lt;strong&gt;Never&lt;/strong&gt; a direct connection — the singleton pattern alone is insufficient&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Lambda&lt;/td&gt;
&lt;td&gt;RDS Proxy or self-managed PgBouncer, transaction mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Always-on K8s / Fly.io / Railway&lt;/td&gt;
&lt;td&gt;Global singleton client, &lt;code&gt;connection_limit = floor(max_connections / max_pods) - buffer&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase hosted&lt;/td&gt;
&lt;td&gt;Supavisor on port 6543 for app traffic; port 5432 for migrations only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge Runtime / Middleware&lt;/td&gt;
&lt;td&gt;HTTP driver only — V8 isolates have no TCP sockets, so &lt;code&gt;pg&lt;/code&gt;, &lt;code&gt;postgres.js&lt;/code&gt;, and standard Prisma simply cannot run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local dev&lt;/td&gt;
&lt;td&gt;Direct connection, but configure &lt;code&gt;DIRECT_URL&lt;/code&gt; anyway so prod parity isn't a surprise&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On that Edge Runtime row, the simplest advice is the best advice: &lt;strong&gt;don't query your database from Edge Runtime&lt;/strong&gt; unless you're on an HTTP driver. Move database access to the Node.js runtime and reserve the edge for work that only touches KV or cache.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Short Version
&lt;/h2&gt;

&lt;p&gt;A Postgres connection is a forked process with megabytes of private memory, the right to allocate &lt;code&gt;work_mem&lt;/code&gt; per plan node, and a snapshot that holds back vacuum. &lt;code&gt;max_connections&lt;/code&gt; is a memory budget wearing a counter's clothing.&lt;/p&gt;

&lt;p&gt;The only number the database sees is &lt;code&gt;N instances × pool size&lt;/code&gt;, plus workers, cron, migrations, exporters, and reserved slots. Rolling deploys briefly double the app tier, which is why these incidents track deploys rather than traffic.&lt;/p&gt;

&lt;p&gt;Small pools are faster under load. &lt;code&gt;L = λW&lt;/code&gt; puts required concurrency at arrival rate × hold time — usually single digits. When the pool saturates, cut &lt;code&gt;W&lt;/code&gt;; don't raise &lt;code&gt;C&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Transaction-mode pooling costs session state, and announces it through errors that mention prepared statements rather than pooling.&lt;/p&gt;

&lt;p&gt;Never hold a connection across a call you don't control, because &lt;code&gt;statement_timeout&lt;/code&gt; cannot interrupt a backend that isn't executing anything.&lt;/p&gt;

&lt;p&gt;And instrument pool-wait time. It's the interval that holds your p99 during every one of these failures, and it appears on nobody's dashboard by default.&lt;/p&gt;

&lt;p&gt;Connection pooling isn't a performance optimisation. It's a shared budget with a hard ceiling, spent by more consumers than anyone has counted — and in serverless, the spending is done by a concurrency number you don't control.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>serverless</category>
      <category>performance</category>
    </item>
    <item>
      <title>PostgreSQL 19 vs. 18: What Actually Changed, Feature by Feature</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Mon, 27 Jul 2026 15:23:36 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/postgresql-19-vs-18-what-actually-changed-feature-by-feature-4cp0</link>
      <guid>https://dev.to/jatinjainsaraf/postgresql-19-vs-18-what-actually-changed-feature-by-feature-4cp0</guid>
      <description>&lt;h1&gt;
  
  
  PostgreSQL 19 vs. 18: What Actually Changed, Feature by Feature
&lt;/h1&gt;

&lt;p&gt;Every major PostgreSQL release gets the same LinkedIn headline treatment: "this changes everything." Most of the time it doesn't, it's a dozen genuine improvements wrapped in the language of a paradigm shift. PostgreSQL 19 is a real release with real substance, but the honest way to evaluate it isn't the headline feature, it's asking what specifically didn't work in PG18 that now does, and what the actual cost of adopting it is.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Shape of the Two Releases
&lt;/h3&gt;

&lt;p&gt;PostgreSQL 18 was primarily an &lt;strong&gt;I/O and observability&lt;/strong&gt; release: the Asynchronous I/O subsystem rewired how the engine reads from disk, &lt;code&gt;pg_stat_io&lt;/code&gt; got a near-total overhaul, and B-Tree skip scans fixed a decade-old multicolumn-index limitation. PostgreSQL 19 is primarily a &lt;strong&gt;DDL/DML ergonomics and operational-maintenance&lt;/strong&gt; release: the headline feature (SQL/PGQ) is a new query language surface, but the features that will actually change your on-call life are &lt;code&gt;REPACK CONCURRENTLY&lt;/code&gt;, native partition reshaping, and parallel autovacuum.&lt;/p&gt;

&lt;p&gt;That distinction matters because it tells you where to look for value. If PG18 already fixed your I/O-bound scan performance, PG19 isn't going to double it again, it's going to fix the maintenance operations that PG18 left untouched.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. SQL/PGQ: Property Graph Queries
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; PostgreSQL 19 adds SQL/PGQ, the SQL:2023 standard for querying graph-shaped data. You define a &lt;strong&gt;property graph&lt;/strong&gt; as a read-only view over existing relational tables (a node table, an edge table), then query it with &lt;code&gt;MATCH&lt;/code&gt; and Neo4j-style arrow syntax: &lt;code&gt;MATCH (a:Employee)-[:MANAGES]-&amp;gt;(b:Employee)&lt;/code&gt;. Internally, PostgreSQL rewrites the arrow syntax into ordinary joins against your existing tables before the planner ever runs, so it inherits your existing indexes and your existing row-level security automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comparison (PG18 vs. PG19):&lt;/strong&gt; In PG18, and every version before it, modeling a graph relationship (a dependency tree, an org chart, an authorization graph, "who reports to whom") meant writing a recursive CTE, &lt;code&gt;WITH RECURSIVE&lt;/code&gt;, walking a self-referencing table one level at a time. Recursive CTEs work, but they're procedural: you write the recursion, you write the termination condition, and the planner frequently struggles to estimate the cost of a deep or unbounded traversal, which is exactly the kind of query that "chokes the planner" that gets complained about on social media. There was no declarative way to say "find all paths matching this pattern" — you had to hand-build the loop yourself in SQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No secondary graph database, no sync pipeline. If your graph queries are shallow-to-medium depth over data that's already relational, you get graph-shaped querying without standing up Neo4j and building an ETL job to keep it current.&lt;/li&gt;
&lt;li&gt;Declarative pattern matching gives the planner a clearer picture of intent than a hand-written recursive CTE, which can lead to better plans for the same logical query.&lt;/li&gt;
&lt;li&gt;Inherits your existing security model for free, since a property graph is "just a view."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's still running joins under the hood. You do &lt;strong&gt;not&lt;/strong&gt; get index-free adjacency, the property that makes a native graph database like Neo4j fast for deep, multi-hop traversal (5+ hops over millions of edges). If your actual workload is that kind of traversal, SQL/PGQ won't rescue you from needing a dedicated graph store.&lt;/li&gt;
&lt;li&gt;New syntax surface means new things to learn and new things the planner can misjudge; it's not yet battle-tested the way recursive CTEs are, having existed for two decades.&lt;/li&gt;
&lt;li&gt;The realistic audience for this feature is "developers who currently reach for a recursive CTE for shallow hierarchical queries," not "teams running production graph analytics at Neo4j scale." Know which one you are before switching.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;REPACK&lt;/code&gt;: Unifying and Unlocking &lt;code&gt;VACUUM FULL&lt;/code&gt; / &lt;code&gt;CLUSTER&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; PostgreSQL 19 introduces a single &lt;code&gt;REPACK&lt;/code&gt; command that replaces both &lt;code&gt;VACUUM FULL&lt;/code&gt; (rewrite the table to reclaim bloat) and &lt;code&gt;CLUSTER&lt;/code&gt; (rewrite the table in index order). The critical addition is a &lt;code&gt;CONCURRENTLY&lt;/code&gt; option — &lt;code&gt;REPACK ... CONCURRENTLY&lt;/code&gt; — that rebuilds the table &lt;strong&gt;without&lt;/strong&gt; taking the &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock that made both of the old commands unusable on a live, high-traffic table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comparison (PG18 vs. PG19):&lt;/strong&gt; In PG18, if a table had accumulated enough bloat (dead row versions from updates/deletes that &lt;code&gt;VACUUM&lt;/code&gt; alone couldn't reclaim) that you needed to physically shrink it, your only in-core option was &lt;code&gt;VACUUM FULL&lt;/code&gt;, which locks the table exclusively for the entire rewrite. On a table serving live traffic, that's not a maintenance task, it's a scheduled outage. The workaround was the third-party &lt;code&gt;pg_repack&lt;/code&gt; extension, which achieves a similar result without full locking by building a shadow copy and swapping it in, but that means installing and trusting an external extension for something this fundamental.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This is the single biggest operational upgrade in the release for anyone who has had to fight table bloat on a production system: the capability that &lt;code&gt;pg_repack&lt;/code&gt; (the extension) existed specifically to provide is now in core, with &lt;code&gt;CONCURRENTLY&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;One command name instead of two (&lt;code&gt;VACUUM FULL&lt;/code&gt; and &lt;code&gt;CLUSTER&lt;/code&gt; remain for backward compatibility, but &lt;code&gt;REPACK&lt;/code&gt; is now the unified entry point).&lt;/li&gt;
&lt;li&gt;Removes a dependency on a third-party extension for a core maintenance operation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CONCURRENTLY&lt;/code&gt; avoiding the exclusive lock doesn't mean it's free, it still consumes I/O and CPU rewriting the table, and the new &lt;code&gt;max_repack_replication_slots&lt;/code&gt; variable exists because concurrent repack has its own resource considerations to tune.&lt;/li&gt;
&lt;li&gt;If your team already has &lt;code&gt;pg_repack&lt;/code&gt; (the extension) working reliably, migrating to core &lt;code&gt;REPACK CONCURRENTLY&lt;/code&gt; is a "nice to have simplify," not an urgent fix, don't rip out something that already works without testing the native replacement first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Native Partition Merge/Split
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; &lt;code&gt;ALTER TABLE ... MERGE PARTITIONS&lt;/code&gt; and &lt;code&gt;ALTER TABLE ... SPLIT PARTITIONS&lt;/code&gt;, letting you reshape an existing partitioned table's partition boundaries natively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comparison (PG18 vs. PG19):&lt;/strong&gt; PG18 invested heavily in partition &lt;em&gt;query performance&lt;/em&gt;, more efficient planning over many partitions, better partitionwise joins, reduced memory use for partition pruning, but did nothing for partition &lt;em&gt;maintenance&lt;/em&gt;. If a monthly partition grew too large and you wanted to split it into two, or several small partitions had accumulated and you wanted to merge them, there was no native command. You did it by hand: create new partition(s), migrate the relevant rows with &lt;code&gt;INSERT ... SELECT&lt;/code&gt;, detach and drop the old partition, all while carefully managing locks and application downtime windows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Turns a multi-step, error-prone, DBA-scripted operation into a single DDL statement.&lt;/li&gt;
&lt;li&gt;Makes it realistic to actually right-size partitions over time as data volume and access patterns change, rather than living with whatever partition scheme you picked at design time.&lt;/li&gt;
&lt;li&gt;Complements PG18's partition query-planning work: PG18 made partitioned tables fast to &lt;em&gt;query&lt;/em&gt;, PG19 makes them practical to &lt;em&gt;maintain&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Any partition reshape on a large table still means physically moving rows, this is not a free metadata-only operation, plan for I/O and lock impact accordingly.&lt;/li&gt;
&lt;li&gt;Doesn't retroactively fix a bad initial partitioning key choice, it makes &lt;em&gt;boundary&lt;/em&gt; changes easier, not a full re-partitioning strategy change.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. &lt;code&gt;GROUP BY ALL&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; New &lt;code&gt;SELECT&lt;/code&gt; syntax, &lt;code&gt;GROUP BY ALL&lt;/code&gt;, which automatically groups by every column in the &lt;code&gt;SELECT&lt;/code&gt; list that isn't an aggregate or window function, no manual enumeration required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comparison (PG18 vs. PG19):&lt;/strong&gt; PG18 addressed a related but distinct problem at the planner level: it learned to &lt;em&gt;ignore&lt;/em&gt; &lt;code&gt;GROUP BY&lt;/code&gt; columns that were functionally dependent on other grouped columns (for example, if you group by a table's unique primary key, other same-table columns don't logically need to be listed, and PG18's planner recognized this and dropped them from the actual grouping operation for efficiency). That's an internal cost optimization, it didn't change what you had to &lt;em&gt;type&lt;/em&gt;. In PG18 you still hand-wrote every column in the &lt;code&gt;GROUP BY&lt;/code&gt; clause, no matter how wide the &lt;code&gt;SELECT&lt;/code&gt; list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Removes the tedious, error-prone task of keeping a long &lt;code&gt;GROUP BY&lt;/code&gt; list in sync with the &lt;code&gt;SELECT&lt;/code&gt; list, adding a column to one and forgetting the other is a classic source of "column must appear in GROUP BY" errors.&lt;/li&gt;
&lt;li&gt;Particularly valuable for wide analytical queries with 10+ grouping dimensions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Implicit grouping means it's slightly easier to accidentally group by more (or fewer) columns than you intended if the &lt;code&gt;SELECT&lt;/code&gt; list changes later, explicit lists are more self-documenting for complex queries. Use with intention, not as a default habit for every query.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. &lt;code&gt;FOR PORTION OF&lt;/code&gt;: Temporal UPDATE/DELETE
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; New clause for &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;FOR PORTION OF &amp;lt;period&amp;gt; FROM &amp;lt;start&amp;gt; TO &amp;lt;end&amp;gt;&lt;/code&gt;, that lets you modify or delete just a sub-range of a temporal (range-based) row, automatically splitting the surrounding range as needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comparison (PG18 vs. PG19):&lt;/strong&gt; PG18 introduced the &lt;em&gt;constraint&lt;/em&gt; half of temporal tables: &lt;code&gt;WITHOUT OVERLAPS&lt;/code&gt; for &lt;code&gt;PRIMARY KEY&lt;/code&gt;/&lt;code&gt;UNIQUE&lt;/code&gt; and &lt;code&gt;PERIOD&lt;/code&gt; for foreign keys, letting you enforce that time ranges in a table never overlap. But it gave you no corresponding &lt;em&gt;operation&lt;/em&gt; half. If you had a row valid from Jan–Dec and needed to change just the March–June portion, you had to manually delete the original row and insert two (or three) new rows representing the split ranges yourself, exactly the kind of fiddly, off-by-one-prone logic a database feature should be doing for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Closes the gap PG18 left half-finished: PG18 gave you guardrails (non-overlapping constraints), PG19 gives you the verbs (safely editing a slice of history without hand-rolling the split).&lt;/li&gt;
&lt;li&gt;Meaningful for any system tracking effective-dated data, pricing history, contract terms, HR assignment periods, insurance coverage windows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Temporal tables remain a niche feature relative to PostgreSQL's overall audience, most applications don't model data this way, and adopting &lt;code&gt;WITHOUT OVERLAPS&lt;/code&gt; + &lt;code&gt;FOR PORTION OF&lt;/code&gt; is a genuine schema-design commitment, not a drop-in swap.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6. &lt;code&gt;INSERT ... ON CONFLICT DO SELECT ... RETURNING&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; Extends &lt;code&gt;ON CONFLICT&lt;/code&gt; beyond &lt;code&gt;DO NOTHING&lt;/code&gt;/&lt;code&gt;DO UPDATE&lt;/code&gt; with a new &lt;code&gt;DO SELECT ... RETURNING&lt;/code&gt; option, returning (and optionally locking, via &lt;code&gt;FOR UPDATE&lt;/code&gt;/&lt;code&gt;FOR SHARE&lt;/code&gt;) the row that caused the conflict, without modifying it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comparison (PG18 vs. PG19):&lt;/strong&gt; PG18 made real progress on returning row state, adding &lt;code&gt;OLD&lt;/code&gt;/&lt;code&gt;NEW&lt;/code&gt; alias support to &lt;code&gt;RETURNING&lt;/code&gt; across &lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt;/&lt;code&gt;MERGE&lt;/code&gt;, so you could see before-and-after values in a single statement. But &lt;code&gt;ON CONFLICT&lt;/code&gt; itself was unchanged: still only &lt;code&gt;DO NOTHING&lt;/code&gt; (silently skip, tell you nothing about the existing row) or &lt;code&gt;DO UPDATE&lt;/code&gt; (you must actually modify something to get a &lt;code&gt;RETURNING&lt;/code&gt; result). The common "get-or-create" pattern, insert a row if it doesn't exist, otherwise just give me the existing one, had no clean native expression; teams worked around it with a no-op &lt;code&gt;DO UPDATE SET col = col&lt;/code&gt; just to trigger a &lt;code&gt;RETURNING&lt;/code&gt; clause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Directly solves get-or-create without a no-op write, which matters for both clarity and for avoiding unnecessary row versions from a fake update (fewer wasted row versions means less bloat, which loops back to why &lt;code&gt;REPACK CONCURRENTLY&lt;/code&gt; matters less often).&lt;/li&gt;
&lt;li&gt;Optional row locking (&lt;code&gt;FOR UPDATE&lt;/code&gt;/&lt;code&gt;FOR SHARE&lt;/code&gt;) on the conflicting row means you can safely read-then-act on it within the same statement, useful for concurrent upsert-adjacent logic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Another &lt;code&gt;ON CONFLICT&lt;/code&gt; branch to learn and to get right in mixed application code that already juggles &lt;code&gt;DO NOTHING&lt;/code&gt; and &lt;code&gt;DO UPDATE&lt;/code&gt; logic; worth auditing existing upsert helper functions to see where this actually simplifies things versus where it's unnecessary.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  7. TOAST Default Compression: &lt;code&gt;pglz&lt;/code&gt; → &lt;code&gt;lz4&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; The default compression algorithm for TOASTed (large, out-of-line) values changes from &lt;code&gt;pglz&lt;/code&gt; to &lt;code&gt;lz4&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comparison (PG18 vs. PG19):&lt;/strong&gt; &lt;code&gt;lz4&lt;/code&gt; TOAST compression already existed as an option in PG18 (and earlier), you could set &lt;code&gt;default_toast_compression = lz4&lt;/code&gt; or specify it per-column. But almost nobody did, because defaults are what most schemas actually run with. PG18 shipped with &lt;code&gt;pglz&lt;/code&gt; as the out-of-the-box default; PG19 flips that default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A genuinely free win for anyone storing large JSONB, text, or bytea values who never manually tuned this setting, &lt;code&gt;lz4&lt;/code&gt; compresses and decompresses meaningfully faster than &lt;code&gt;pglz&lt;/code&gt; at a comparable ratio.&lt;/li&gt;
&lt;li&gt;Zero migration effort for new databases; the new default just applies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Existing tables don't retroactively recompress, this only affects newly TOASTed values going forward (or values rewritten via &lt;code&gt;REPACK&lt;/code&gt;/&lt;code&gt;VACUUM FULL&lt;/code&gt;), so an existing large database won't see the benefit until data is naturally rewritten or explicitly reprocessed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  8. Parallel Autovacuum Workers
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; A single autovacuum job on one table can now use multiple parallel workers, controlled globally by &lt;code&gt;autovacuum_max_parallel_workers&lt;/code&gt; and per-table by the &lt;code&gt;autovacuum_parallel_workers&lt;/code&gt; storage parameter. PG19 also adds a scoring system (&lt;code&gt;autovacuum_vacuum_score_weight&lt;/code&gt;, &lt;code&gt;autovacuum_freeze_score_weight&lt;/code&gt;, and related variables) to decide which tables get processed first, replacing a simpler threshold-only heuristic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comparison (PG18 vs. PG19):&lt;/strong&gt; PG18 improved autovacuum's &lt;em&gt;behavior&lt;/em&gt; significantly: "eager freezing" let normal vacuums freeze all-visible pages proactively (reducing the cost of a later full freeze), &lt;code&gt;autovacuum_worker_slots&lt;/code&gt; let you raise the effective worker cap at runtime without a restart, and &lt;code&gt;autovacuum_vacuum_max_threshold&lt;/code&gt; let you set a fixed dead-tuple trigger point instead of relying purely on percentages. What PG18 didn't change: &lt;strong&gt;each individual vacuum job on a given table still ran as one single worker process&lt;/strong&gt;. On a genuinely huge, high-churn table, that one worker was the throughput ceiling, no matter how many total autovacuum worker slots your cluster had available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Directly attacks the specific case that bites teams running very large, high-write tables: a vacuum job that used to take hours on one worker can now split the work across several, on the same table, at the same time.&lt;/li&gt;
&lt;li&gt;The new scoring system for processing order is a real improvement over pure threshold checks, better prioritizing which of many candidate tables actually needs attention most urgently.&lt;/li&gt;
&lt;li&gt;Complements, rather than duplicates, PG18's improvements, PG18 made each vacuum pass smarter and more proactive; PG19 makes the biggest passes faster by parallelizing them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More parallel workers means more concurrent I/O and CPU contention during vacuum; on a system already tight on resources, this needs the same careful tuning any parallelism feature does, it's not automatically a net win without headroom to spend.&lt;/li&gt;
&lt;li&gt;Per-table &lt;code&gt;autovacuum_parallel_workers&lt;/code&gt; is one more tuning knob DBAs now need to understand and set deliberately for their biggest tables, rather than relying entirely on cluster-wide defaults.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  9. Logical Replication: Native Sequence Synchronization
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; Sequences can now be included in logical replication. &lt;code&gt;CREATE&lt;/code&gt;/&lt;code&gt;ALTER PUBLICATION ... ALL SEQUENCES&lt;/code&gt; publishes all sequences, and &lt;code&gt;ALTER SUBSCRIPTION ... REFRESH SEQUENCES&lt;/code&gt; syncs sequence &lt;em&gt;values&lt;/em&gt; (not just existence) on the subscriber to match the publisher. &lt;code&gt;pg_get_sequence_data()&lt;/code&gt; lets you inspect sync state directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comparison (PG18 vs. PG19):&lt;/strong&gt; PG18 made solid logical replication improvements, generated column values could finally be replicated, and the default streaming mode for new subscriptions switched from &lt;code&gt;off&lt;/code&gt; to &lt;code&gt;parallel&lt;/code&gt; for better apply performance. But sequences were entirely untouched by logical replication in PG18 and every prior version: a subscriber's sequences had no relationship to the publisher's. If you promoted a logically-replicated subscriber to primary during a failover, its sequences would still be wherever they last were on that subscriber, not caught up to the publisher's actual &lt;code&gt;nextval()&lt;/code&gt; position, unless you manually reset them yourself before cutting traffic over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Removes a genuinely dangerous, well-known logical-replication gap: without this, a poorly-timed failover onto a logically-replicated subscriber can hand out primary-key values that collide with rows the old primary already committed, a duplicate-ID bug hiding specifically in your disaster-recovery path, the worst possible place for a bug to hide since it only shows up when you're already in an incident.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pg_get_sequence_data()&lt;/code&gt; gives visibility into sync state that simply didn't exist before, useful for confirming a subscriber is actually safe to promote.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This is a correctness fix for a previously-silent gap, not a performance feature, teams need to actively adopt &lt;code&gt;ALL SEQUENCES&lt;/code&gt; and &lt;code&gt;REFRESH SEQUENCES&lt;/code&gt; in their subscription setup; it isn't retroactively applied to existing subscriptions without action.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Which of These Actually Change How You Operate Postgres
&lt;/h3&gt;

&lt;p&gt;Ranking by realistic production impact, not headline appeal:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;REPACK CONCURRENTLY&lt;/code&gt;&lt;/strong&gt; — removes a hard operational constraint (mandatory downtime for bloat reclaim) that has existed since the beginning of Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parallel autovacuum&lt;/strong&gt; — directly extends throughput on the exact tables where autovacuum has historically fallen behind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logical replication sequence sync&lt;/strong&gt; — closes a real correctness gap sitting specifically in failover paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partition merge/split&lt;/strong&gt; — makes partition schemes maintainable as data grows, rather than fixed at design time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQL/PGQ&lt;/strong&gt; — genuinely useful for a specific class of query (shallow hierarchical/relationship modeling), not a universal graph-database replacement, know which camp you're in before treating it as a headline reason to upgrade.&lt;/li&gt;
&lt;li&gt;Everything else (&lt;code&gt;GROUP BY ALL&lt;/code&gt;, &lt;code&gt;FOR PORTION OF&lt;/code&gt;, &lt;code&gt;ON CONFLICT DO SELECT&lt;/code&gt;, TOAST &lt;code&gt;lz4&lt;/code&gt; default) — real, welcome ergonomics and default-quality improvements, but incremental rather than architectural.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Where This Fits
&lt;/h3&gt;

&lt;p&gt;If you're running the &lt;a href="https://academy.jatinjainsaraf.com/postgresql-in-depth" rel="noopener noreferrer"&gt;PostgreSQL In-Depth course&lt;/a&gt;, the maintenance-and-bloat modules built around &lt;code&gt;pg_repack&lt;/code&gt; and manual &lt;code&gt;VACUUM FULL&lt;/code&gt; tradeoffs are the direct prerequisite for understanding why &lt;code&gt;REPACK CONCURRENTLY&lt;/code&gt; matters as much as it does, and the partitioning phase is the natural place to slot in the new merge/split DDL. For the deep architectural picture PG19 builds on top of (process model, MVCC, the planner, WAL), see &lt;a href="https://insight.jatinjainsaraf.com/the-postgresql-elephant-in-the-room-a-deep-dive-into-the-architecture-that-powers-giants" rel="noopener noreferrer"&gt;"The PostgreSQL Elephant in the Room."&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;PostgreSQL 19 isn't a rewrite of what Postgres is, it's the same server process, managing files intelligently, that &lt;a href="https://insight.jatinjainsaraf.com/what-the-postgresql-server-is-actually-doing" rel="noopener noreferrer"&gt;"What the PostgreSQL Server Is Actually Doing"&lt;/a&gt; describes, just with more of the maintenance and ergonomic rough edges sanded down. That's not a smaller story than "Postgres killed the recursive CTE", it's a more honest one.&lt;/p&gt;

&lt;h1&gt;
  
  
  PostgreSQL #Database #Backend #SoftwareEngineering #DatabaseInternals #SQL #DevOps
&lt;/h1&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>backend</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Idempotency Keys: How to Make Retries Safe in Distributed Systems</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Fri, 24 Jul 2026 20:34:05 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/idempotency-keys-how-to-make-retries-safe-in-distributed-systems-1mcp</link>
      <guid>https://dev.to/jatinjainsaraf/idempotency-keys-how-to-make-retries-safe-in-distributed-systems-1mcp</guid>
      <description>&lt;h1&gt;
  
  
  Idempotency Keys: How to Make Retries Safe in Distributed Systems
&lt;/h1&gt;

&lt;p&gt;A customer clicks "Pay Now" once. The request reaches your payment service, the charge succeeds, but the response times out on the way back. The client, seeing no answer, retries. Somewhere in your logs there are now two successful charges for one click. Nobody wrote a bug. The network just did what networks do.&lt;/p&gt;




&lt;h3&gt;
  
  
  Retries Are Not Optional, They're the Default
&lt;/h3&gt;

&lt;p&gt;In a single process, a function call either returns or the whole program crashes with it. In a distributed system, that guarantee disappears. A request can fail before it reaches the server, after the server processes it but before the response comes back, or anywhere in between, and from the caller's side, all three failures look identical: silence, or a timeout.&lt;/p&gt;

&lt;p&gt;Given that, a caller has exactly two honest choices: give up, or retry. Almost every serious system chooses to retry, because giving up on a payment, an order, or a blockchain transaction just because a router hiccupped is worse than the alternative. The problem is that "the alternative" retrying turns a network problem into a correctness problem, unless the operation you're retrying can tolerate being run more than once.&lt;/p&gt;

&lt;p&gt;That property has a name: &lt;strong&gt;idempotency&lt;/strong&gt;. An operation is idempotent if running it once and running it five times leave the system in the same state. &lt;code&gt;SET x = 5&lt;/code&gt; is idempotent — running it twice still leaves &lt;code&gt;x&lt;/code&gt; at 5. &lt;code&gt;x = x + 5&lt;/code&gt; is not, run it twice and you've added 10. Retrying a network call is safe by default only when the operation behind it is naturally idempotent. Charging a card, placing an order, sending an email — none of these are. That's the gap idempotency keys exist to close.&lt;/p&gt;

&lt;h3&gt;
  
  
  How an Idempotency Key Actually Works
&lt;/h3&gt;

&lt;p&gt;The mechanism is simpler than the name suggests. The client generates a unique key, typically a UUID, once, at the moment the user takes the action, before any request is sent. That key is attached to the request, usually as a header (&lt;code&gt;Idempotency-Key: 8f14e...&lt;/code&gt;). Every retry of that same logical action, the same click, the same order, reuses the exact same key.&lt;/p&gt;

&lt;p&gt;On the server, before doing any real work, the first thing that happens is a lookup: has this key been seen before?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not seen before&lt;/strong&gt; → this is genuinely a new request. Process it (charge the card, place the order), store the key alongside the result, and return.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seen before, and finished&lt;/strong&gt; → this is a retry of something already done. Skip the actual work entirely and return the &lt;em&gt;stored result&lt;/em&gt; from the first attempt. The client gets the same success response it would have gotten the first time, and no charge happens twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seen before, and still in progress&lt;/strong&gt; → a retry arrived while the original request is still being processed (a slow response, not a failed one). The correct move is to make the retry wait or reject it, never to run the operation again concurrently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That storage of "key → result" is the entire trick. It turns "run this action" into "run this action, but only the first time you're asked, and remember what happened so every later ask gets the same answer." The database row or cache entry holding that key is doing the same job as the WAL in PostgreSQL, giving you a durable record of intent so a retry, a crash, or a redelivery doesn't have to guess what already happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Matters in Production, Not Just in Theory
&lt;/h3&gt;

&lt;p&gt;This isn't a hypothetical edge case; it's the default behavior of almost every reliability mechanism you already rely on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Payment gateways retry.&lt;/strong&gt; Stripe, for instance, builds idempotency keys into its API specifically because a client-side timeout on a successful charge is common enough to have a name in their docs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Message queues retry.&lt;/strong&gt; SQS, Kafka consumers, and most queue systems offer &lt;em&gt;at-least-once&lt;/em&gt; delivery, not &lt;em&gt;exactly-once&lt;/em&gt;, because exactly-once delivery across a network is provably expensive to guarantee. "At least once, so dedupe on your end" is the honest tradeoff, and idempotency keys are "your end." This is exactly why background job systems like BullMQ build &lt;a href="https://academy.jatinjainsaraf.com/nodejs-in-depth/background-jobs-bullmq" rel="noopener noreferrer"&gt;retries with exponential backoff and dead letter queues&lt;/a&gt; into the framework itself, rather than leaving it to each job handler to reinvent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile clients retry.&lt;/strong&gt; A user on a bad connection taps "Submit" once, but the app, seeing no response after a few seconds, quietly retries the request behind the scenes. Without an idempotency key, that one tap becomes two orders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load balancers and proxies retry.&lt;/strong&gt; Some infrastructure automatically retries a request that failed to connect, before your application code ever sees it happen once, let alone twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks retry, by design.&lt;/strong&gt; Stripe, GitHub, and most webhook senders will redeliver an event if your endpoint doesn't return a fast 2xx response, on the assumption that a timeout means you never got it. &lt;a href="https://academy.jatinjainsaraf.com/nodejs-in-depth/external-services-caching" rel="noopener noreferrer"&gt;Idempotent webhook processing&lt;/a&gt; — checking the event ID before acting on it — is the only thing standing between that redelivery and a duplicate side effect on your end.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without an idempotency key sitting between "the network is unreliable" and "the operation isn't naturally repeatable," every one of these standard, unremarkable mechanisms becomes a live double-charge, double-order, or double-send bug waiting for the right timing to trigger it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Example: Why a Blockchain Indexer Cannot Survive Without This
&lt;/h3&gt;

&lt;p&gt;Nowhere is this more visible, or more unforgiving, than in a blockchain indexer, and it's worth walking through concretely.&lt;/p&gt;

&lt;p&gt;An indexer's whole job is to watch a chain, pull each new block, extract the events or transfers inside it, and write them into your own database so your application can query them fast. That sounds like a simple one-pass pipeline: get block, process block, move on. In practice it never runs exactly once per block, for reasons entirely outside your control:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reorgs.&lt;/strong&gt; The chain itself can rewind and replay a range of blocks when a fork resolves. Your indexer will see block 18,402,001 more than once, as two different, competing versions of "reality."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crash recovery.&lt;/strong&gt; If your indexer process dies mid-batch, on restart it doesn't know precisely which of the last few blocks were fully written versus half-written, so the safe move is to reprocess a small overlapping window, not trust a fragile "last processed block" pointer to be exactly right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RPC retries.&lt;/strong&gt; The call to fetch a block from a node can itself time out and get retried, occasionally landing you the same block payload twice from two separate requests racing each other.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now picture what happens without idempotency built in: a transfer event says "500 USDC moved from wallet A to wallet B." If that event gets processed twice, because of a reorg replay or a crash-recovery reprocess, and your indexing logic does &lt;code&gt;balance += 500&lt;/code&gt; each time it sees the event, wallet B's balance is now wrong by 500 USDC in your database, permanently, until someone notices the number doesn't match the chain and re-runs a full backfill. On a system indexing tens of millions of events, that's not a rare accident, it's a near-certainty over enough uptime.&lt;/p&gt;

&lt;p&gt;The fix is the exact same pattern as the payment example, just with a different key. Instead of a client-generated UUID, the natural idempotency key is something already unique to the data itself: &lt;code&gt;(transaction_hash, log_index)&lt;/code&gt; for an EVM chain, for instance, uniquely identifies one specific event, no matter how many times it's delivered to you. The write becomes an upsert keyed on that pair, not a blind &lt;code&gt;balance += amount&lt;/code&gt;. Seen this &lt;code&gt;(tx_hash, log_index)&lt;/code&gt; before? Skip it, or overwrite with the identical result, never add on top of it again. Reorgs, crash-recovery reprocessing, and duplicate RPC responses all become harmless, because the operation of "record this event" is now idempotent, exactly the same property a payment API gets from a client-supplied key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where to Put the Key
&lt;/h3&gt;

&lt;p&gt;Idempotency keys work at whatever layer needs the guarantee, and the source of the key changes depending on who's best positioned to know "this is the same logical request":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client-generated&lt;/strong&gt; (payments, order placement, form submissions): the client mints a UUID once, before the first attempt, and resends the identical key on every retry of that same user action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data-derived&lt;/strong&gt; (blockchain events, webhook deliveries, message queue consumers): the key comes from something already unique in the payload itself, a transaction hash, a webhook delivery ID, a message ID, so you never have to coordinate key generation between sender and receiver at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way, the underlying requirement is the same: the key must be stored durably, checked before any side effect runs, and scoped to an appropriate time window (payment idempotency keys are typically only guaranteed for 24 hours; a blockchain indexer's &lt;code&gt;(tx_hash, log_index)&lt;/code&gt; uniqueness is effectively permanent).&lt;/p&gt;

&lt;h3&gt;
  
  
  Where This Fits in the Full Course
&lt;/h3&gt;

&lt;p&gt;Idempotent webhook processing and BullMQ retry/backoff patterns are both covered hands-on in the &lt;a href="https://academy.jatinjainsaraf.com/nodejs-in-depth" rel="noopener noreferrer"&gt;Node.js In-Depth course&lt;/a&gt;, in the Practitioner phase — module P-7 ("Connecting External Services and Caching") and module P-12 ("Background Jobs and Task Queues with BullMQ"), respectively.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Question Worth Asking Yourself
&lt;/h3&gt;

&lt;p&gt;Next time you write a piece of code that has a side effect, charges something, sends something, increments something, and sits behind a network call, ask one question before you ship it: &lt;em&gt;if this exact request arrived twice, on purpose or by accident, would the result still be correct?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If the honest answer is no, that's not a rare failure mode you're accepting, it's a bug you've already written. The network will find it for you eventually. It's better to find it first.&lt;/p&gt;

&lt;h1&gt;
  
  
  DistributedSystems #SoftwareEngineering #Backend #SystemDesign #Blockchain #APIDesign
&lt;/h1&gt;

</description>
      <category>backend</category>
      <category>systemdesign</category>
      <category>distributedsystems</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How the LRU Cache Actually Works and Why Redis, Browsers, and Postgres All Approximate It</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Tue, 21 Jul 2026 19:19:44 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/how-the-lru-cache-actually-works-and-why-redis-browsers-and-postgres-all-approximate-it-30id</link>
      <guid>https://dev.to/jatinjainsaraf/how-the-lru-cache-actually-works-and-why-redis-browsers-and-postgres-all-approximate-it-30id</guid>
      <description>&lt;p&gt;You've felt this before: you restart a service, or open your laptop after the weekend, and everything is sluggish for the first few minutes. Every page load feels like it's dragging. Then, without you doing anything, it speeds back up. Nothing changed in your code. What changed is that the cache went cold, and it just warmed back up.&lt;/p&gt;

&lt;p&gt;Almost every system that makes this speed-up possible Redis, your browser, your database, even a function that "remembers" its last few results is solving the exact same problem underneath: it has limited room, so when that room runs out, it has to decide what to throw away first. The answer nearly all of them land on is the same one: throw away whatever hasn't been touched in the longest time. That policy has a name Least Recently Used, or LRU and the way it's actually built is one of the more satisfying "two weak pieces make one strong piece" stories in software.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem neither obvious answer solves
&lt;/h2&gt;

&lt;p&gt;Say you want to build this yourself: a fixed-size cache that instantly tells you if something is stored, and instantly evicts the "stalest" entry the moment you're full.&lt;/p&gt;

&lt;p&gt;Your first instinct might be a plain lookup table a dictionary, a hash map, whatever your language calls it. That gives you instant answers to "is this here?" But a lookup table has no concept of time. It doesn't know that key A was touched ten seconds ago and key B was touched ten minutes ago. To find the stalest entry, you'd have to check everything slow, and it gets slower as the cache grows.&lt;/p&gt;

&lt;p&gt;So your second instinct might be a simple ordered list keep every entry in a line, most recently used at the front. That solves the ordering problem. But now finding a specific entry means walking the line from the front until you happen to find it. And worse, once you find it, moving it to the front means pulling it out of the middle of the line which, in a plain list, means shifting everything around it.&lt;/p&gt;

&lt;p&gt;Neither piece alone works. One gives you instant lookup with no memory of time. The other gives you a sense of time with no instant lookup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: don't choose, combine
&lt;/h2&gt;

&lt;p&gt;The actual solution used almost everywhere is to stop treating "look something up" and "track its age" as the same job, and instead let one structure handle each, wired together.&lt;/p&gt;

&lt;p&gt;Picture a line of people, ordered by who was served most recently front of the line is "just here," back of the line is "hasn't been seen in ages." That's your time-ordering structure, and because it's a &lt;em&gt;doubly linked&lt;/em&gt; line (each person knows who's directly in front of and behind them), pulling anyone out of the middle and moving them to the front is instant you're just relinking a few neighbors, not shuffling the whole line.&lt;/p&gt;

&lt;p&gt;Now add a cashier holding a notebook that maps every person's name directly to their exact spot in that line. You don't scan the line to find someone you check the notebook, and it points you straight at them. That notebook is your lookup table, except its entries don't hold the person's information directly they hold a pointer to where that person is standing.&lt;/p&gt;

&lt;p&gt;Put those two together and something interesting happens: a lookup stops being "search the line" and becomes "check the notebook, then relink two spots in the line." Both steps are instant, regardless of how many people are in line. The lookup table gives you the address; the line gives you a cheap way to reorder once you're there. Neither piece is doing the other's job they're doing their own job, wired to the same underlying entries.&lt;/p&gt;

&lt;p&gt;Eviction is the same trick pointed at the back of the line instead of the front: whoever is standing at the very back is, by definition, the stalest entry so when the cache is full, that's who gets dropped, and the notebook forgets their name too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why real systems don't build this exactly
&lt;/h2&gt;

&lt;p&gt;Here's the part worth sitting with: at genuinely large scale, most real caching systems &lt;em&gt;don't&lt;/em&gt; implement pure LRU, because keeping a perfectly ordered "line" updated on literally every single read becomes its own overhead once you're handling millions of operations a second.&lt;/p&gt;

&lt;p&gt;Redis's LRU eviction, for instance, doesn't maintain one global perfectly-ordered line at all it samples a handful of random keys and evicts whichever of &lt;em&gt;those&lt;/em&gt; looks stalest, repeated as needed. It's an approximation, and a good one, because it gets 90% of the benefit of true LRU for a fraction of the bookkeeping cost.&lt;/p&gt;

&lt;p&gt;Your browser's HTTP cache and CDN edge caches lean on the same core idea evict whatever's gone longest untouched once storage fills up to decide what to drop when they hit their limits, which is exactly why re-visiting a page you loaded five minutes ago feels instant, but a page from three weeks ago fetches fresh.&lt;/p&gt;

&lt;p&gt;Databases play the same game one layer down. A database's in-memory buffer pool the pages of the table currently held in RAM instead of read from disk often uses something called clock-sweep instead of textbook LRU: cheaper to maintain, same underlying goal of keeping "hot" pages in memory and letting cold ones get evicted first.&lt;/p&gt;

&lt;p&gt;And plenty of ordinary application code hits this without ever calling it LRU by name "cache the last 500 computed results, and once that limit is hit, drop whatever hasn't been asked for in a while" is the identical problem, just at a size where the textbook version runs perfectly well with no approximation needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual takeaway
&lt;/h2&gt;

&lt;p&gt;The mechanism is almost deceptively small once you see it: pair a lookup table with an ordered line, and let the table's entries point directly into the line instead of duplicating what's in it. That's the whole trick. What's genuinely interesting is how far that one idea travels from a toy interview problem, to the reason your database feels fast after warming up, to the reason revisiting a webpage is instant and revisiting an old one isn't. You've probably relied on a system built this way today without ever knowing it had a name.&lt;/p&gt;

</description>
      <category>dsa</category>
      <category>systemdesign</category>
      <category>caching</category>
      <category>redis</category>
    </item>
    <item>
      <title>Why a Single ALTER TABLE Can Take Down Your Whole Database</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Wed, 15 Jul 2026 16:05:59 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/why-a-single-alter-table-can-take-down-your-whole-database-1hmp</link>
      <guid>https://dev.to/jatinjainsaraf/why-a-single-alter-table-can-take-down-your-whole-database-1hmp</guid>
      <description>&lt;h1&gt;
  
  
  Why a Single ALTER TABLE Can Take Down Your Whole Database
&lt;/h1&gt;

&lt;p&gt;A team runs a routine migration: &lt;code&gt;ALTER TABLE transactions ADD COLUMN region TEXT&lt;/code&gt;. Nothing dramatic on paper. Twelve minutes later the connection pool is full, every request from every user is failing, and on-call is paged. The migration itself was never the problem. A lock queue was.&lt;/p&gt;




&lt;h3&gt;
  
  
  A Lock Isn't a Wall, It's a Queue Ticket
&lt;/h3&gt;

&lt;p&gt;The word "lock" makes people picture a barrier: something is locked, everything else waits outside. That's not quite how PostgreSQL locking works, and the difference is exactly what causes outages like this one.&lt;/p&gt;

&lt;p&gt;A better picture is a queue ticket. Every statement that touches a table takes a ticket. Some tickets are compatible with each other, holders can be served side by side, no problem. Other tickets are not compatible, and a ticket holder has to wait for the incompatible one ahead of it to finish before it can be served.&lt;/p&gt;

&lt;p&gt;Two plain &lt;code&gt;SELECT&lt;/code&gt; statements are always compatible. They take the weakest kind of ticket there is, and a hundred of them can run at once without ever noticing each other. The trouble starts when someone joins the line holding the strongest possible ticket.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Strongest Ticket Blocks Everyone, Even the Compatible Ones
&lt;/h3&gt;

&lt;p&gt;PostgreSQL has a handful of table-level lock strengths, but only one really matters for this story. It's called &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;, and it's acquired by &lt;code&gt;ALTER TABLE&lt;/code&gt;, &lt;code&gt;DROP TABLE&lt;/code&gt;, and &lt;code&gt;TRUNCATE&lt;/code&gt;. It conflicts with every other lock in the system, including a plain read.&lt;/p&gt;

&lt;p&gt;Here's the part that catches people off guard: PostgreSQL mostly serves lock requests in the order they arrive. So if an &lt;code&gt;ALTER TABLE&lt;/code&gt; shows up and has to wait for one long-running &lt;code&gt;SELECT&lt;/code&gt; to finish, it doesn't just wait quietly off to the side. It gets in line. And every &lt;code&gt;SELECT&lt;/code&gt; that shows up after it also has to get in line, behind the &lt;code&gt;ALTER TABLE&lt;/code&gt;, even though those new &lt;code&gt;SELECT&lt;/code&gt; statements would have been perfectly happy running alongside the original one.&lt;/p&gt;

&lt;p&gt;One slow reader plus one waiting migration is enough to freeze every future read on that table, until the slow reader finally lets go.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Twelve Minutes Becomes an Outage
&lt;/h3&gt;

&lt;p&gt;This is close to how it actually plays out in production. An analytics query kicks off against a busy table and, because nobody set a timeout on it, keeps running for twelve minutes. A few minutes in, someone ships a routine schema migration on that same table. The migration asks for its &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; ticket and starts waiting on the analytics query.&lt;/p&gt;

&lt;p&gt;From that moment, every new request the application sends to that table queues up behind the migration. Within half a minute, hundreds of connections are stuck waiting on a lock, not on any actual work. The connection pool, which was never designed to hold hundreds of idle-but-blocked connections, fills up completely. New requests can't even get a connection to wait with. The site goes down, and the root cause line in the postmortem is almost funny in how small it is: one long read, one migration with no timeout.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix Is Two Settings, Not a Rewrite
&lt;/h3&gt;

&lt;p&gt;The defense here isn't clever code, it's two timeouts that should be set before any schema change ever touches a live table.&lt;/p&gt;

&lt;p&gt;The first tells the migration itself to give up quickly if it can't get its lock in a few seconds, rather than parking itself at the front of a queue that keeps growing. The second is a ceiling on how long any query is allowed to run at all, so a stray analytics query can never hold a lock for twelve minutes in the first place. Neither setting is exotic. Both are usually just missing.&lt;/p&gt;

&lt;p&gt;Set correctly, the same migration either succeeds in under a second because nothing was in its way, or it fails immediately and cleanly so you can retry it a minute later. What you never want is the third option: waiting, silently, gathering a crowd behind it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Row Locks Are a Separate, Smaller Story
&lt;/h3&gt;

&lt;p&gt;Everything above is about locking the table's structure. There's a second, unrelated locking system for locking individual rows of data, and it solves a different problem: two transactions trying to change the same row at the same time.&lt;/p&gt;

&lt;p&gt;The classic example is a balance check before a debit. Read the balance, confirm there's enough money, then subtract the amount. Without a row lock, two concurrent withdrawals can both read the same starting balance and both proceed, and the account ends up wrong. Locking that one row for the duration of the transaction closes the gap.&lt;/p&gt;

&lt;p&gt;There's also a neat variant built for queues: instead of locking a row and making every other worker wait for it, a worker can ask to skip any row that's already locked and grab the next free one instead. That's the difference between ten background workers piling up behind each other and ten workers each picking up a different job at the same instant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deadlocks: When Two Correct Transactions Still Collide
&lt;/h3&gt;

&lt;p&gt;Occasionally two transactions each hold a lock the other one needs. Transaction A has locked account 1 and wants account 2. Transaction B has locked account 2 and wants account 1. Neither can proceed, and neither will ever let go voluntarily. PostgreSQL notices this after about a second, picks one of the two transactions, and kills it so the other can continue.&lt;/p&gt;

&lt;p&gt;The fix isn't a database setting, it's a coding discipline: always acquire locks in the same order, everywhere in the codebase. If every transfer function locks the lower account ID first regardless of direction, the two transactions above stop being able to collide at all. It's the same trick as everyone in a crowded hallway agreeing to keep to the right.&lt;/p&gt;

&lt;h3&gt;
  
  
  Locks With No Data Attached
&lt;/h3&gt;

&lt;p&gt;PostgreSQL also offers a kind of lock that has nothing to do with any table or row: an application-defined lock, identified by whatever number you choose. It's mainly used for one thing, making sure a background job runs on only one server at a time, even when there are several application instances that could all try to start it. Grab the lock, run the job, and if the process crashes mid-job, the lock releases itself automatically instead of leaving things stuck. It's a lighter, more reliable substitute for the Redis-based mutex a lot of teams reach for by default.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Takeaway
&lt;/h3&gt;

&lt;p&gt;A lock isn't something you fight, it's a ticket you're standing in line with, and most locking outages don't start with a bad lock. They start with someone who forgot to set a timeout and ended up holding the line for everyone behind them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where This Fits in the Full Course
&lt;/h3&gt;

&lt;p&gt;This is Module 13 of the &lt;a href="https://academy.jatinjainsaraf.com/postgresql-in-depth" rel="noopener noreferrer"&gt;PostgreSQL In-Depth course&lt;/a&gt;, from the Architect phase covering the internals senior engineers eventually have to learn the hard way. If this scenario feels familiar, the earlier modules on &lt;a href="https://academy.jatinjainsaraf.com/postgresql-in-depth/mvcc" rel="noopener noreferrer"&gt;MVCC&lt;/a&gt; and transaction internals build the concurrency model this one depends on.&lt;/p&gt;

&lt;h1&gt;
  
  
  PostgreSQL #Database #Backend #SQL #ProductionIncidents #SoftwareEngineering
&lt;/h1&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>backend</category>
      <category>sql</category>
    </item>
    <item>
      <title>What the PostgreSQL Server Is Actually Doing</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Sat, 11 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/what-the-postgresql-server-is-actually-doing-55fm</link>
      <guid>https://dev.to/jatinjainsaraf/what-the-postgresql-server-is-actually-doing-55fm</guid>
      <description>&lt;h1&gt;
  
  
  What the PostgreSQL Server Is Actually Doing
&lt;/h1&gt;

&lt;p&gt;When you run &lt;code&gt;CREATE DATABASE&lt;/code&gt;, PostgreSQL creates a directory. When you run &lt;code&gt;INSERT&lt;/code&gt;, it writes a row to a file and logs the write for crash safety. When you run &lt;code&gt;SELECT&lt;/code&gt;, it reads that file back. That's the whole trick, once you see it, the "magic" disappears.&lt;/p&gt;




&lt;h3&gt;
  
  
  What the PostgreSQL Server Is Actually Doing
&lt;/h3&gt;

&lt;p&gt;Most developers use PostgreSQL for years without ever forming a picture of what it's doing when a query runs. SQL goes in, rows come out, and everything in between feels like a sealed box. It isn't a sealed box. It's a program, running on a computer, reading and writing files, same as any other program you've written. This article builds that basic picture, in plain language, with no prior database knowledge assumed.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Database Is a Directory
&lt;/h3&gt;

&lt;p&gt;Run &lt;code&gt;CREATE DATABASE learning_postgres&lt;/code&gt; and PostgreSQL doesn't do anything exotic. It creates a directory on disk to hold that database's data. That's it. Every database you create gets its own folder, sitting under PostgreSQL's data directory, the same way any application on your computer might create a folder to store its files.&lt;/p&gt;

&lt;p&gt;This is worth sitting with, because it quietly answers a question that trips up a lot of people: "where does my data actually live?" It lives in a directory on the disk of whatever machine is running the PostgreSQL server. Not in the cloud in some abstract sense, not floating in "the database." On disk, in files, in a folder PostgreSQL controls.&lt;/p&gt;

&lt;p&gt;It also explains something you may have noticed without thinking about it: &lt;code&gt;CREATE DATABASE&lt;/code&gt; returns almost instantly, milliseconds, not minutes. That's because creating a database is just creating an empty directory. There's no data in it yet. The real work, and the real cost, only begins once rows start filling those files.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Table Is a File, a Row Is Bytes in That File
&lt;/h3&gt;

&lt;p&gt;Inside that directory, each table you create is backed by its own file (large tables get split across several, but the idea holds). When you run:&lt;/p&gt;

&lt;p&gt;INSERT INTO users (name, email) VALUES ('Alice', '&lt;a href="mailto:alice@example.com"&gt;alice@example.com&lt;/a&gt;');&lt;/p&gt;

&lt;p&gt;PostgreSQL takes that row and writes it, as bytes, into the file backing the &lt;code&gt;users&lt;/code&gt; table. Nothing more mysterious than that.&lt;/p&gt;

&lt;p&gt;When you run:&lt;/p&gt;

&lt;p&gt;SELECT * FROM users WHERE email = '&lt;a href="mailto:alice@example.com"&gt;alice@example.com&lt;/a&gt;';&lt;/p&gt;

&lt;p&gt;PostgreSQL opens that file (or consults an index that points into it, more on indexes in a later module), scans through the rows stored there, checks each one against your &lt;code&gt;WHERE&lt;/code&gt; condition, and returns the ones that match.&lt;/p&gt;

&lt;p&gt;Think of the file as a very disciplined spreadsheet that only PostgreSQL is allowed to touch directly. You never open it yourself, you always go through SQL, but structurally, that's what it is: rows, stored as bytes, in a file, on disk.&lt;/p&gt;

&lt;p&gt;Under the hood, PostgreSQL is really answering a short chain of questions before it hands you an answer: Is the data I need already sitting in memory, or do I have to go to disk for it? Is there a shortcut, an index, that gets me there without reading the whole file? And once I've found a row, is it actually the current, correct version, or an old one nobody needs anymore? You don't write any of that logic. But knowing it happens is what lets you start reasoning about &lt;em&gt;why&lt;/em&gt; one query is fast and an almost-identical one is slow.&lt;/p&gt;

&lt;h3&gt;
  
  
  PostgreSQL Is a Process, Not a Black Box
&lt;/h3&gt;

&lt;p&gt;When you connect to PostgreSQL, whether from &lt;code&gt;psql&lt;/code&gt;, a Node.js app, or a GUI tool, you're talking to a running process on a server. That process listens for your connection, hands you off to a dedicated worker just for your session, and from that point on, every query you send is handled by an ordinary program doing ordinary things: reading bytes from files, writing bytes to files, holding some of that data in memory so it doesn't have to touch the disk every single time.&lt;/p&gt;

&lt;p&gt;That last part, keeping frequently-used data in memory, is why the &lt;em&gt;second&lt;/em&gt; time you query something is usually much faster than the first. The process remembers what it recently read from disk and serves it from memory instead. This is the same idea as any caching you've done in application code, just built into the database itself.&lt;/p&gt;

&lt;p&gt;This is also why a query can get slower for no reason you can find in your SQL. If PostgreSQL just restarted, deployed, crashed, rebooted, that memory is empty again. Nothing is cached yet. The first round of queries has to go back to disk to rebuild that cache from scratch, before things speed back up. If you've ever seen a database feel sluggish right after a restart and assumed you'd broken something, this is usually the real reason: the cache went cold, not your code.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Log That Saves You From Crashes
&lt;/h3&gt;

&lt;p&gt;Here's the one detail that separates "just writing to a file" from what a production database actually needs to guarantee: what happens if the power goes out, or the process crashes, in the middle of a write?&lt;/p&gt;

&lt;p&gt;PostgreSQL's answer: before it changes the actual data file, it first writes down what it's &lt;em&gt;about to do&lt;/em&gt;, to a separate log. Only after that note is safely on disk does it go ahead and make the real change.&lt;/p&gt;

&lt;p&gt;If the server crashes mid-write, it doesn't matter, on restart, PostgreSQL reads that log and replays anything that didn't finish. It's the same instinct as keeping a to-do list before starting a task: if you get interrupted, you don't have to remember what you were doing, you just check the list. This log is one of the reasons people trust PostgreSQL with data they can't afford to lose.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Mental Model Matters
&lt;/h3&gt;

&lt;p&gt;Once "PostgreSQL is a process that reads and writes files, with a safety log protecting every write" is in your head, a lot of things that used to feel like separate pieces of magic start looking like variations on the same idea:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Indexes&lt;/strong&gt; are just extra files that let PostgreSQL find the right rows without scanning the whole table.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Caching (shared buffers)&lt;/strong&gt; is just PostgreSQL keeping recently-used file contents in memory.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Replication&lt;/strong&gt; is just another server reading that same safety log and replaying it, to keep a second copy of the data in sync.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;VACUUM&lt;/strong&gt; is just PostgreSQL cleaning up old row versions it no longer needs, so the files don't grow forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are separate systems bolted onto a mysterious core. They're all extensions of the same basic loop: read files, write files, log before you write, keep useful things in memory.&lt;/p&gt;

&lt;p&gt;PostgreSQL isn't magic. It's a server process that manages files intelligently, and every advanced feature it has is that idea, applied more cleverly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where This Fits in the Full Course
&lt;/h3&gt;

&lt;p&gt;This is the very first mental model from the Foundation phase of the &lt;a href="https://academy.jatinjainsaraf.com/postgresql-in-depth" rel="noopener noreferrer"&gt;PostgreSQL In-Depth course&lt;/a&gt;, the phase built for zero prior database knowledge. If this clicked, the next modules build on it directly: the client-server connection model in detail, the relational mental model (tables, rows, and how they connect), and your first complete schema.&lt;/p&gt;

&lt;p&gt;For readers who want the advanced version of this same territory, process architecture, MVCC, the planner, replication internals, see &lt;a href="https://dev.to/blog/the-postgresql-elephant-in-the-room-a-deep-dive-into-the-architecture-that-powers-giants"&gt;"The PostgreSQL Elephant in the Room."&lt;/a&gt; That article assumes you already have the basic picture this one just gave you.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Question Worth Asking Yourself
&lt;/h3&gt;

&lt;p&gt;Next time a PostgreSQL query does something you don't expect, slow, fast, returning stale-looking data, try asking the simplest possible question first: what files is it reading or writing right now, and what would that look like from the outside?&lt;/p&gt;

&lt;p&gt;You'll be surprised how often that question, on its own, points you toward the answer, long before you need to reach for anything more advanced.&lt;/p&gt;

&lt;h1&gt;
  
  
  PostgreSQL #Database #Backend #SoftwareEngineering #DatabaseFundamentals #LearnToCode #SQL
&lt;/h1&gt;

</description>
      <category>database</category>
      <category>postgres</category>
      <category>backend</category>
      <category>fundamentals</category>
    </item>
    <item>
      <title>Competition Is Inevitable. Cruelty Is Optional</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Tue, 30 Jun 2026 14:47:34 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/competition-is-inevitable-cruelty-is-optional-4bfb</link>
      <guid>https://dev.to/jatinjainsaraf/competition-is-inevitable-cruelty-is-optional-4bfb</guid>
      <description>&lt;h1&gt;
  
  
  Competition Is Inevitable. Cruelty Is Optional.
&lt;/h1&gt;

&lt;p&gt;Every promotion in tech has an invisible downside.&lt;/p&gt;




&lt;ul&gt;
&lt;li&gt;When someone becomes an Intern, thousands of applicants don't.&lt;/li&gt;
&lt;li&gt;When someone becomes a Junior Developer, another candidate receives a rejection email.&lt;/li&gt;
&lt;li&gt;When someone becomes a Senior Developer, someone else waits another review cycle.&lt;/li&gt;
&lt;li&gt;When someone becomes a Tech Lead or Engineering Manager, dozens of equally ambitious engineers aren't selected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the reality of a competitive industry.&lt;/p&gt;

&lt;p&gt;But here's where many people get it wrong.&lt;/p&gt;

&lt;p&gt;Success isn't about destroying people. It's about becoming the best choice.&lt;/p&gt;

&lt;p&gt;You don't need to sabotage a colleague.&lt;br&gt;
You don't need office politics.&lt;br&gt;
You don't need to hope others fail.&lt;/p&gt;

&lt;p&gt;You need to build skills that make the decision obvious.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Learn continuously.&lt;/li&gt;
&lt;li&gt;Take ownership.&lt;/li&gt;
&lt;li&gt;Communicate clearly.&lt;/li&gt;
&lt;li&gt;Deliver consistently.&lt;/li&gt;
&lt;li&gt;Solve bigger problems than yesterday.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Competition is inevitable.&lt;br&gt;
Cruelty is optional.&lt;/p&gt;

&lt;p&gt;The goal isn't to terminate careers. The goal is to become so valuable that opportunities naturally come your way.&lt;/p&gt;

&lt;p&gt;In the end, the market doesn't reward the loudest engineer.&lt;/p&gt;

&lt;p&gt;It rewards the one who consistently creates the most value.&lt;/p&gt;

&lt;p&gt;Outperform the competition. Respect the competitors.&lt;/p&gt;

&lt;h1&gt;
  
  
  SoftwareEngineering #CareerGrowth #Leadership #TechCareers #Engineering #ContinuousLearning #TechLeadership
&lt;/h1&gt;

</description>
      <category>career</category>
      <category>growth</category>
      <category>leadership</category>
    </item>
    <item>
      <title>Transactions and ACID in Practice: What Every Backend Developer Must Know</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Fri, 26 Jun 2026 18:45:18 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/transactions-and-acid-in-practice-what-every-backend-developer-must-know-4hmh</link>
      <guid>https://dev.to/jatinjainsaraf/transactions-and-acid-in-practice-what-every-backend-developer-must-know-4hmh</guid>
      <description>&lt;p&gt;Most backend developers think they understand transactions.&lt;br&gt;
Then a server crashes mid-transfer and $100 disappears between two SQL statements.&lt;/p&gt;

&lt;p&gt;I wrote a deep dive on Transactions &amp;amp; ACID in practice isolation levels, FOR UPDATE SKIP LOCKED for job queues, why long transactions silently destroy performance, and the Node.js patterns that actually get it right.&lt;/p&gt;

&lt;p&gt;Read the full article → &lt;a href="https://insight.jatinjainsaraf.com/blog/transactions-and-acid-in-practice-what-every-backend-developer-must-know" rel="noopener noreferrer"&gt;https://insight.jatinjainsaraf.com/blog/transactions-and-acid-in-practice-what-every-backend-developer-must-know&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;From the PostgreSQL In-Depth course, built from years running Postgres at TB scale → &lt;a href="https://academy.jatinjainsaraf.com/courses/postgresql-in-depth" rel="noopener noreferrer"&gt;https://academy.jatinjainsaraf.com/courses/postgresql-in-depth&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;PostgreSQL Backend Database SoftwareEngineering&lt;/p&gt;

</description>
    </item>
    <item>
      <title>When Judgment Becomes the Bottleneck</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Sun, 21 Jun 2026 17:29:30 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/when-judgment-becomes-the-bottleneck-djl</link>
      <guid>https://dev.to/jatinjainsaraf/when-judgment-becomes-the-bottleneck-djl</guid>
      <description>&lt;p&gt;There's a moment in most senior engineers' careers that nobody warns you about.&lt;/p&gt;

&lt;p&gt;You've earned trust. Your instincts are sharp.&lt;br&gt;
People come to you before committing to a design. Before merging a PR. Before choosing a database. Before making a call.&lt;/p&gt;

&lt;p&gt;And slowly, without noticing it, you stop being an engineer and start being a gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap looks like success.
&lt;/h2&gt;

&lt;p&gt;You're being consulted because your judgment is valued.&lt;br&gt;
You're in every important meeting because your perspective matters.&lt;br&gt;
You're the person who "gets it."&lt;/p&gt;

&lt;p&gt;But here's what's actually happening:&lt;/p&gt;

&lt;p&gt;PRs sit waiting for your review.&lt;br&gt;
Decisions stall until you're available.&lt;br&gt;
Engineers stop thinking through options, because they'll just ask you anyway.&lt;br&gt;
Your calendar fills with meetings that exist to get your approval.&lt;/p&gt;

&lt;p&gt;The team isn't scaling. It's depending.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is what judgment-as-bottleneck looks like:
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Velocity is tied to your availability, not the team's capacity&lt;/li&gt;
&lt;li&gt;Junior engineers learn your answers, not your reasoning&lt;/li&gt;
&lt;li&gt;Decisions that could have been made in an hour wait three days&lt;/li&gt;
&lt;li&gt;You feel indispensable, but the system is actually fragile&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The irony? The more you care about quality, the more likely you are to accidentally create this pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix isn't doing less. It's building judgment transfer.
&lt;/h2&gt;

&lt;p&gt;Share the &lt;em&gt;why&lt;/em&gt; behind decisions, not just the decision.&lt;br&gt;
Write down the heuristics you use, the instincts you've built over years deserve to be externalised.&lt;br&gt;
Define decision boundaries: "You own anything under this scope. Come to me only when it crosses these lines."&lt;br&gt;
Let people make calls you'd have made differently, then debrief rather than override.&lt;/p&gt;

&lt;p&gt;The best engineering leaders I've seen don't protect quality by owning every decision.&lt;br&gt;
They protect it by raising the quality of how the team decides.&lt;/p&gt;

&lt;p&gt;Your judgment shouldn't be the ceiling. It should be the foundation.&lt;/p&gt;




&lt;p&gt;When you make yourself the bottleneck, even unintentionally, you're not protecting quality.&lt;br&gt;
You're just delaying it.&lt;/p&gt;

&lt;p&gt;The real work isn't being right every time.&lt;br&gt;
It's building a team that can be right when you're not in the room.&lt;/p&gt;

&lt;p&gt;💬 Have you ever caught yourself becoming the bottleneck,  or working under one?&lt;/p&gt;

&lt;h1&gt;
  
  
  EngineeringLeadership #TechLead #SoftwareEngineering #LeadershipMindset #TeamScaling
&lt;/h1&gt;

</description>
      <category>engineeringleadershi</category>
      <category>techlead</category>
      <category>leadership</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Engineering Courses By JJS</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Thu, 11 Jun 2026 16:59:59 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/engineering-courses-by-jjs-23e9</link>
      <guid>https://dev.to/jatinjainsaraf/engineering-courses-by-jjs-23e9</guid>
      <description>&lt;p&gt;Most tutorials teach you how to build a "Hello World" app.&lt;br&gt;
But how do you actually build, scale, and optimize a full-stack application for production?&lt;/p&gt;

&lt;p&gt;I’m thrilled to announce the launch of my new Academy—a dedicated learning hub for serious backend and full-stack engineers.&lt;/p&gt;

&lt;p&gt;I’ve launched with 4 comprehensive, deep-dive courses:&lt;/p&gt;

&lt;p&gt;🐘 PostgreSQL In-Depth MVCC, WAL, Autovacuum, &amp;amp; Query Planning. &lt;br&gt;
🔗 &lt;a href="https://academy.jatinjainsaraf.com/postgresql-in-depth" rel="noopener noreferrer"&gt;https://academy.jatinjainsaraf.com/postgresql-in-depth&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🟢 Node.js In-Depth Event Loop internals, streams, and performance tuning. &lt;br&gt;
🔗 &lt;a href="https://academy.jatinjainsaraf.com/nodejs-in-depth" rel="noopener noreferrer"&gt;https://academy.jatinjainsaraf.com/nodejs-in-depth&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;⚛️ Next.js In-Depth Server components, caching strategies, and advanced routing. &lt;br&gt;
🔗 &lt;a href="https://academy.jatinjainsaraf.com/nextjs-in-depth" rel="noopener noreferrer"&gt;https://academy.jatinjainsaraf.com/nextjs-in-depth&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;⚡ Redis In-Depth Caching patterns, persistence, and pub/sub architecture. &lt;br&gt;
🔗 &lt;a href="https://academy.jatinjainsaraf.com/redis-in-depth" rel="noopener noreferrer"&gt;https://academy.jatinjainsaraf.com/redis-in-depth&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🟢 Master JavaScript Testing&lt;br&gt;
🔗 &lt;a href="https://academy.jatinjainsaraf.com/master-javascript-testing" rel="noopener noreferrer"&gt;https://academy.jatinjainsaraf.com/master-javascript-testing&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🟢 Docker In-Depth&lt;br&gt;
🔗 &lt;a href="https://academy.jatinjainsaraf.com/docker-in-depth" rel="noopener noreferrer"&gt;https://academy.jatinjainsaraf.com/docker-in-depth&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is how the Academy works:&lt;/p&gt;

&lt;p&gt;🔓 100% Open Access I hate paywalls. You can read every single module in all 4 courses right now, completely free, without even logging in.&lt;/p&gt;

&lt;p&gt;💬 Join the Discussion Got a question about a specific concept? Create a free account to unlock comments and talk directly with me and the community.&lt;/p&gt;

&lt;p&gt;Ready to level up your engineering skills?&lt;/p&gt;

&lt;p&gt;Browse the full catalog and start reading here: &lt;br&gt;
🔗 &lt;a href="https://academy.jatinjainsaraf.com/" rel="noopener noreferrer"&gt;https://academy.jatinjainsaraf.com/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let me know which course you are diving into first! 👇&lt;/p&gt;

&lt;h1&gt;
  
  
  SoftwareEngineering #WebDevelopment #PostgreSQL #NodeJS #NextJS #Redis #TechCommunity
&lt;/h1&gt;

</description>
      <category>backend</category>
      <category>learning</category>
      <category>node</category>
      <category>postgres</category>
    </item>
    <item>
      <title>https://insight.jatinjainsaraf.com/mastering-postgresql-5-essential-tips-for-performance-optimization</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Thu, 11 Jun 2026 16:56:14 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/-284l</link>
      <guid>https://dev.to/jatinjainsaraf/-284l</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://insight.jatinjainsaraf.com/mastering-postgresql-5-essential-tips-for-performance-optimization" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Finsight.jatinjainsaraf.com%2Fapi%2Fog%2Fblog%2Fmastering-postgresql-5-essential-tips-for-performance-optimization" height="630" class="m-0" width="1200"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://insight.jatinjainsaraf.com/mastering-postgresql-5-essential-tips-for-performance-optimization" rel="noopener noreferrer" class="c-link"&gt;
            Mastering PostgreSQL: 5 Essential Tips for Performance Optimization — Jatin Jain Saraf
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            When your database slows down, your whole application suffers. Discover 5 essential PostgreSQL optimization tips—from EXPLAIN ANALYZE to smart indexing—that…
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Finsight.jatinjainsaraf.com%2Ffavicon.ico" width="48" height="48"&gt;
          insight.jatinjainsaraf.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
    </item>
  </channel>
</rss>
