<?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>Every Postgres Write Triggers Five Background Processes. Here's What Each One Actually Does.</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Mon, 14 Sep 2026 05:05:17 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/every-postgres-write-triggers-five-background-processes-heres-what-each-one-actually-does-3a1n</link>
      <guid>https://dev.to/jatinjainsaraf/every-postgres-write-triggers-five-background-processes-heres-what-each-one-actually-does-3a1n</guid>
      <description>&lt;p&gt;An &lt;code&gt;UPDATE&lt;/code&gt; statement returns in two milliseconds. From where you're sitting, that's the whole story: the row changed, the client got an acknowledgment, done. Underneath, Postgres just did five separate jobs to make that write durable, recoverable, reusable, and still fast for the next query to plan correctly. Most of the time you never see any of them. Then a dashboard shows periodic latency spikes with no slow query in sight, or &lt;code&gt;pg_stat_activity&lt;/code&gt; shows a backend "stuck" on something with a name you've never had to learn, and you're suddenly debugging a part of Postgres that has been running the entire time.&lt;/p&gt;

&lt;p&gt;This is a walkthrough of that background layer: the Write-Ahead Log, checkpoints, the wait events that make both of them visible, and the vacuum/analyze cycle that cleans up after every write and keeps the query planner honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  WAL: the black box that makes crash recovery possible
&lt;/h2&gt;

&lt;p&gt;Postgres never writes a change directly to a table's data file first. It writes a record of the change to the Write-Ahead Log (WAL) first, and only later, asynchronously, does the actual data file get updated. This is the same idea as an aircraft's flight data recorder: it doesn't prevent anything from going wrong, but if the plane goes down, the black box is what lets you reconstruct exactly what happened, in order, right up to the last recorded moment.&lt;/p&gt;

&lt;p&gt;If Postgres crashes between "WAL record written" and "data file updated," the data file is allowed to be behind. On restart, Postgres replays the WAL from the last confirmed checkpoint forward and reconstructs every change that was durably logged but not yet applied to the heap. That's the entire crash-recovery guarantee, and it's also the entire replication guarantee: a replica is, at its core, a process continuously replaying WAL records shipped from the primary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; &lt;code&gt;synchronous_commit&lt;/code&gt; controls whether a transaction waits for its WAL record to actually hit disk before returning success to the client. Turn it off (or set it to a weaker level like &lt;code&gt;local&lt;/code&gt;) and writes get faster, because you're no longer waiting on an fsync. You're also now willing to lose the last few transactions if the server crashes before that WAL hits disk. That's a real trade-off teams make deliberately for high-throughput, loss-tolerant write paths, and a real trade-off teams make by accident when they copy a config from a benchmark blog post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checkpoints: the point WAL gets allowed to forget the past
&lt;/h2&gt;

&lt;p&gt;WAL can't grow forever, and replaying an unbounded WAL after a crash would make recovery take unbounded time. A checkpoint is Postgres periodically flushing every dirty page sitting in shared buffers out to the actual data files, so that everything the WAL described up to that point is now durably reflected on disk. Once a checkpoint completes, the WAL segments before it are no longer needed for crash recovery and can be recycled or removed (subject to what replication and archiving still need them for).&lt;/p&gt;

&lt;p&gt;Checkpoints run on a schedule controlled by two settings: &lt;code&gt;checkpoint_timeout&lt;/code&gt; (time-based, five minutes by default) and &lt;code&gt;max_wal_size&lt;/code&gt; (a soft cap on how much WAL can accumulate before a checkpoint is forced early). Whichever threshold is hit first triggers the checkpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; a checkpoint means writing out every dirty page at once, which is a burst of I/O. If that burst happens all at once, you get the classic "checkpoint spike," periodic latency jumps that correlate with wall-clock time, not query volume. &lt;code&gt;checkpoint_completion_target&lt;/code&gt; exists specifically to spread that I/O out over the checkpoint interval instead of dumping it all at once. If your latency graphs show a sawtooth pattern with a period matching your &lt;code&gt;checkpoint_timeout&lt;/code&gt;, that's usually the first setting worth checking, not the query itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wait events: the window into both of the above
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pg_stat_activity&lt;/code&gt; has two columns, &lt;code&gt;wait_event_type&lt;/code&gt; and &lt;code&gt;wait_event&lt;/code&gt;, that tell you what a backend is actually blocked on right now, if it's blocked on anything. This is the diagnostic surface that turns "the query is slow" into "the query is waiting on something specific":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;wait_event&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;wait_event_type&lt;/code&gt; column groups events into categories: &lt;code&gt;Lock&lt;/code&gt; (waiting on a row, table, or advisory lock held by another transaction), &lt;code&gt;LWLock&lt;/code&gt; (waiting on an internal lightweight lock protecting shared memory structures), &lt;code&gt;IO&lt;/code&gt; (waiting on an actual disk read or write), &lt;code&gt;BufferPin&lt;/code&gt;, &lt;code&gt;IPC&lt;/code&gt;, &lt;code&gt;Timeout&lt;/code&gt;, and a few others. A backend showing &lt;code&gt;IO&lt;/code&gt; wait events tied to WAL activity, or an &lt;code&gt;LWLock&lt;/code&gt; wait tied to the WAL insert path, is a backend that's been caught behind the exact mechanism described above: it's trying to get its change durably logged and something (usually disk throughput, sometimes a checkpoint mid-flush) is making it wait its turn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; the specific wait event names attached to WAL and checkpoint activity have changed across major Postgres versions as the I/O statistics system was reworked, so don't memorize a table of exact strings from a blog post (including this one) and assume it matches your version. Instead, learn the query above, run it against your own instance during a slow period, and read whatever &lt;code&gt;wait_event_type&lt;/code&gt;/&lt;code&gt;wait_event&lt;/code&gt; pair comes back against the current docs for your version. That habit outlives any specific release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vacuum: cleaning up after MVCC, not just reclaiming disk
&lt;/h2&gt;

&lt;p&gt;Postgres never overwrites a row in place on &lt;code&gt;UPDATE&lt;/code&gt; or &lt;code&gt;DELETE&lt;/code&gt;. MVCC (multi-version concurrency control) means an &lt;code&gt;UPDATE&lt;/code&gt; writes a new row version and marks the old one dead, and a &lt;code&gt;DELETE&lt;/code&gt; just marks a row dead, because other transactions that started before yours might still need to see the old version. Those dead row versions don't disappear on their own. &lt;code&gt;VACUUM&lt;/code&gt; is the process that finds them, confirms no transaction still needs them, and marks that space reusable by future inserts on the same table.&lt;/p&gt;

&lt;p&gt;Plain &lt;code&gt;VACUUM&lt;/code&gt; does not shrink the file on disk (only &lt;code&gt;VACUUM FULL&lt;/code&gt; does, by rewriting the whole table, which takes an exclusive lock). It reclaims space &lt;em&gt;within&lt;/em&gt; the existing file for future rows to reuse. &lt;code&gt;autovacuum&lt;/code&gt; runs this automatically, table by table, once the number of dead tuples crosses a threshold (&lt;code&gt;autovacuum_vacuum_threshold&lt;/code&gt; plus a percentage of the table's row count, &lt;code&gt;autovacuum_vacuum_scale_factor&lt;/code&gt;, which defaults to 20%). Vacuum also has a second job unrelated to space at all: it's what prevents transaction ID wraparound, a much rarer but far more serious failure mode where an un-vacuumed table's transaction ID counter runs out of room entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; vacuum generates its own WAL records, since it's modifying pages, so a large autovacuum run on a big table shows up as real write volume, not a free background chore. It also competes for the same I/O bandwidth a checkpoint is using, which is why an aggressive autovacuum kicking off right as a checkpoint is flushing dirty pages is a common, specific cause of a latency spike that doesn't correlate with any query change. &lt;a href="https://dev.to/blog/database-efficiency-101-understanding-bloat-vacuum-and-the-power-of-pg_repack"&gt;Database Efficiency 101&lt;/a&gt; goes deeper on the bloat side of this; the piece worth adding here is that vacuum isn't an isolated background job, it's sharing the same WAL and I/O path as everything above.&lt;/p&gt;

&lt;h2&gt;
  
  
  ANALYZE: the step that keeps the planner honest
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ANALYZE&lt;/code&gt; is a separate operation from vacuum, even though &lt;code&gt;autovacuum&lt;/code&gt; triggers both and people often say "autovacuum" to mean either. &lt;code&gt;ANALYZE&lt;/code&gt; samples a table's rows and updates the planner's statistics (row counts, most common values, distribution histograms) stored in &lt;code&gt;pg_statistic&lt;/code&gt;. The query planner uses those statistics, not a live count, to estimate how many rows a &lt;code&gt;WHERE&lt;/code&gt; clause will match and decide whether a sequential scan, index scan, or bitmap heap scan will be cheapest.&lt;/p&gt;

&lt;p&gt;Stale statistics don't cause a query to return wrong results. They cause the planner to misjudge how many rows it's dealing with, and a bad row-count estimate is one of the most common root causes behind "this query used to be fast and now it isn't," with no schema change and no data corruption involved, just a planner making a good decision on outdated information. &lt;code&gt;autovacuum_analyze_threshold&lt;/code&gt; and &lt;code&gt;autovacuum_analyze_scale_factor&lt;/code&gt; (10% by default) control when this runs automatically, similarly to vacuum's thresholds, and an insert-heavy, delete-free table (an append-only events table, for instance) still needs &lt;code&gt;ANALYZE&lt;/code&gt; on a schedule even though it may rarely need vacuuming for dead tuples.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; after a large bulk load, either via a migration or an initial data import, running &lt;code&gt;ANALYZE&lt;/code&gt; explicitly before serving real traffic is worth the few seconds it costs. Waiting for autovacuum to notice on its own schedule means the planner may make its first few hours of decisions on statistics from before the table had any real data in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting the five back together
&lt;/h2&gt;

&lt;p&gt;None of these are separate systems bolted onto Postgres. They're one pipeline: WAL makes every write durable and replicable, checkpoints periodically settle that WAL's guarantees into the actual data files and bound how long crash recovery takes, wait events are the live diagnostic surface showing you when a backend is stuck somewhere in that path, vacuum reclaims the space MVCC leaves behind and prevents wraparound, and &lt;code&gt;ANALYZE&lt;/code&gt; keeps the planner's model of your data close enough to reality to keep picking good plans. A slow query is sometimes a bad query. Just as often, in a system that's been running fine for months, it's one of these five processes falling behind, and &lt;code&gt;pg_stat_activity&lt;/code&gt; is the first place that tells you which one.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>wal</category>
      <category>vacuum</category>
    </item>
    <item>
      <title>Your ORM Isn't Lying to You. It's Just Not Telling You Everything.</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Thu, 03 Sep 2026 13:32:46 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/your-orm-isnt-lying-to-you-its-just-not-telling-you-everything-35k4</link>
      <guid>https://dev.to/jatinjainsaraf/your-orm-isnt-lying-to-you-its-just-not-telling-you-everything-35k4</guid>
      <description>&lt;p&gt;An ORM is a translator sitting in a business meeting. It's fluent, it's fast, and after a while you stop paying attention to the original language entirely. You trust it. Most days that trust is fine. Then one sentence gets mistranslated, the deal falls apart, and you realize you never actually learned the language yourself. You just learned to rely on someone who did.&lt;/p&gt;

&lt;p&gt;That's what Prisma, Drizzle, TypeORM, and Sequelize do to your relationship with Postgres. They don't lie. They just don't tell you everything, and the gaps are exactly where production incidents live.&lt;/p&gt;

&lt;p&gt;This post is not "ORMs are bad, use raw SQL." Most apps should use one. It's a map of the specific places where the ORM's model of your database and Postgres's actual behavior diverge, so you know where to look when something is slow, wrong, or gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why teams reach for an ORM in the first place
&lt;/h2&gt;

&lt;p&gt;Before the shortcomings, the honest case for using one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Types generated from your schema.&lt;/strong&gt; Rename a column, your build breaks at compile time instead of at 2am in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrations as code.&lt;/strong&gt; Schema changes are version-controlled, reviewable in a pull request, and reproducible across environments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boilerplate CRUD disappears.&lt;/strong&gt; Most application code touching the database is &lt;code&gt;SELECT&lt;/code&gt;, &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt; on one or two tables. An ORM turns that into a few lines instead of a hand-written query and a row-mapping function, every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is wrong. The problem is what it costs you when you stop thinking about the SQL underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  The N+1 query, hiding in plain sight
&lt;/h2&gt;

&lt;p&gt;This is the failure mode that catches the most teams, because the ORM makes it look identical to the correct version.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;users&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;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;users&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;posts&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;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authorId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&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="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;That's 1 query to fetch users, plus N more queries, one per user, to fetch their posts. With 10 users you might not notice. With 10,000 users, those extra round trips can turn an otherwise simple endpoint into a timeout.&lt;/p&gt;

&lt;p&gt;The dangerous part isn't that this pattern exists. Every engineer knows N+1 queries are bad. The dangerous part is that the ORM's relational syntax looks the same whether it's doing this or a single join:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;users&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;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Relation loading isn't necessarily equivalent to the join you would write by hand. Depending on the ORM and its configuration, that one line can compile down to a single query with a join, or to a batch of separate queries stitched together in application code. Prisma, for example, defaults to the second approach for &lt;code&gt;include&lt;/code&gt; on one-to-many relations: it batches the related rows with a SQL &lt;code&gt;IN&lt;/code&gt; clause rather than emitting a native &lt;code&gt;JOIN&lt;/code&gt;, unless you're on a relation mode or raw query that says otherwise. You can't reliably tell which one you got just by reading the application code. Turn on query logging and look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; N+1 doesn't necessarily mean N new connections. Queries can reuse the pool. The problem is the number of database round trips. Under concurrent traffic, many requests running their own N+1 loops can keep pooled connections busy for longer, increasing pool contention and request latency. This is where an ORM's convenience directly creates the incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SQL your ORM actually generates is not the SQL you'd write
&lt;/h2&gt;

&lt;p&gt;Turn on query logging for a week on a real Prisma or TypeORM project and read what comes out. It is rarely what you'd write by hand.&lt;/p&gt;

&lt;p&gt;With nested &lt;code&gt;include&lt;/code&gt;s, some ORMs, Prisma among them, can issue several separate queries and stitch the results together in application code instead of one query with joins. That's not a bug, it's a design trade-off, and it varies by ORM, version, and configuration. But it means &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; on the query you think you're running and the query Postgres actually saw can tell two different stories, and only one of them is visible in your codebase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; if you've read through the &lt;a href="https://dev.to/courses/postgresql-in-depth"&gt;query planning module&lt;/a&gt; of the PostgreSQL In-Depth course, you know Postgres picks between a sequential scan, an index scan, and a bitmap heap scan based on cost estimates for the &lt;em&gt;specific query it receives&lt;/em&gt;. An ORM that reshapes your intent into three smaller queries instead of one joined query gives the planner three separate decisions to make, rather than one plan for the operation as a whole. You lose the planner's ability to optimize across the whole operation.&lt;/p&gt;

&lt;p&gt;The fix isn't abandoning the ORM. It's treating query logging as mandatory, not optional, and periodically running &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; on queries that matter to your application's hot paths or have noticeable latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations: the auto-diff can silently drop data
&lt;/h2&gt;

&lt;p&gt;Most ORMs generate migrations by diffing your schema file against the last known state and producing SQL. This works well for additive changes. It gets dangerous on renames.&lt;/p&gt;

&lt;p&gt;Rename a column in your schema file, and a schema diff doesn't always have enough information to know that you intended a rename rather than a drop-and-add. Some ORMs can detect renames, while others may generate a drop-and-add migration depending on the schema change and migration workflow. The generated migration can be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;full_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;display_name&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of the safe version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;RENAME&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;full_name&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;display_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first version runs without error. It also silently discards every value in that column on deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; a migration that runs clean and drops a column's worth of data doesn't announce itself. Nobody gets an error. You find out when a support ticket comes in asking why a field is suddenly empty. This is one of the highest-leverage things to check before merging any ORM-generated migration: read the actual SQL it produces, not just the schema diff you wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connection pooling: every ORM has different defaults, and it matters more than ever in serverless
&lt;/h2&gt;

&lt;p&gt;If you've read &lt;a href="https://dev.to/blog/connection-pooling-in-the-serverless-era-five-failure-modes"&gt;Connection Pooling in the Serverless Era&lt;/a&gt;, you already know that each connection Postgres accepts consumes server resources, whether or not it's doing anything, and enough concurrent connections becomes a significant resource cost. ORMs each ship their own default pool size, their own idle-timeout behavior, and their own assumptions about how long your process lives.&lt;/p&gt;

&lt;p&gt;Those defaults were mostly written assuming a long-lived server process. In a serverless or edge function, where every invocation can be a fresh process, an ORM's default pool of, say, 10 connections means every cold start tries to open 10 connections to Postgres. Multiply that by concurrent invocations during a traffic spike and you can exhaust your database's max connection limit in seconds, well before you exhaust CPU or memory on the compute side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; this is a common cause of "it works locally, it falls over under load" for teams deploying ORM-based apps to serverless platforms. The fix usually involves an external pooler (PgBouncer, RDS Proxy, or a managed equivalent) sitting between your functions and Postgres, plus explicitly tuning the ORM's own pool size down, not trusting the default. One trap worth knowing before you reach for a pooler: PgBouncer's transaction-mode pooling, the mode most serverless setups need, doesn't preserve a session across queries, and ORMs that rely on prepared statements (Prisma and TypeORM both do, by default) can fail against it in ways that only show up under load. Check your pooler's mode against your ORM's prepared-statement behavior before you assume adding one fixes everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write-heavy systems: where the ORM's convenience becomes the bottleneck
&lt;/h2&gt;

&lt;p&gt;Everything above assumes a fairly typical application: reads dominate, writes happen when a user submits a form or an admin edits a record. That's most CRUD apps, and it's exactly where an ORM's ergonomics pay for themselves. It is not a blockchain indexer, an event pipeline, or anything else ingesting a continuous, high-volume stream of writes. Those systems are where an ORM's row-at-a-time model of the world stops being a convenience and starts being the bottleneck.&lt;/p&gt;

&lt;p&gt;A blockchain indexer is a clean example because the write pattern is unforgiving: every new block can carry hundreds or thousands of events (transfers, contract calls, state changes), and all of them need to land in Postgres before the indexer can call itself caught up. Three places an ORM adds real cost in that path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Row-by-row &lt;code&gt;INSERT&lt;/code&gt; instead of &lt;code&gt;COPY&lt;/code&gt;.&lt;/strong&gt; Postgres's &lt;code&gt;COPY&lt;/code&gt; protocol is built specifically for loading large volumes of rows in one streamed operation, and it is meaningfully faster than even a well-batched multi-row &lt;code&gt;INSERT&lt;/code&gt;. Most ORMs don't expose &lt;code&gt;COPY&lt;/code&gt; at all. Their bulk-insert APIs (&lt;code&gt;createMany&lt;/code&gt;, &lt;code&gt;bulkCreate&lt;/code&gt;, and similar) are a real improvement over inserting one row per statement, but under the hood they're still building &lt;code&gt;INSERT&lt;/code&gt; statements with many value tuples, not streaming through &lt;code&gt;COPY&lt;/code&gt;. For an indexer processing thousands of events per block, that gap compounds every single block.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Object hydration on the way in and out.&lt;/strong&gt; An ORM that maps every row to and from a model instance is doing real work per row: validation, type coercion, applying defaults. That cost is invisible at CRUD volumes and very visible at ingestion volumes, where it adds CPU and allocation overhead to every event in the stream, not just the ones a human is waiting on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More, smaller transactions than the workload needs.&lt;/strong&gt; An ORM's ergonomic defaults tend toward one transaction per logical operation. At indexer-scale throughput, that can mean far more transaction and WAL overhead than a design that deliberately batches writes into fewer, larger transactions per block.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; all three of these show up as the same symptom eventually, insert throughput that can't keep up with the source it's indexing, and the indexer falls further behind with every block. If you've read &lt;a href="https://dev.to/blog/taming-postgresql-replication-lag-in-real-time-blockchain-indexers"&gt;Taming PostgreSQL Replication Lag in Real-Time Blockchain Indexers&lt;/a&gt;, this is the same category of problem from a different angle: it's not always replication configuration that causes an indexer to lag, sometimes it's the write path itself, one ORM abstraction away from the bulk-loading tools Postgres actually gives you for this job.&lt;/p&gt;

&lt;p&gt;None of this means an ORM is disqualified from a write-heavy system. It means the write path deserves the same scrutiny as any other hot path in this article: measure it, and if it's the bottleneck, that's exactly the place to drop to &lt;code&gt;COPY&lt;/code&gt;, hand-batched multi-row &lt;code&gt;INSERT&lt;/code&gt;s, or a dedicated bulk-loading library, and keep the ORM everywhere else it's not in the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the abstraction stops being useful
&lt;/h2&gt;

&lt;p&gt;Every ORM's query builder is designed around the common case: filter, sort, join, paginate. The moment your requirement moves past that, you're negotiating with the abstraction instead of using it. In practice, that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Window functions&lt;/strong&gt; (running totals, ranking within groups) — most ORM query builders don't model these at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recursive CTEs&lt;/strong&gt; (org charts, threaded comments, category trees) — almost always raw SQL, even in projects that otherwise avoid it entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bulk operations&lt;/strong&gt; — an ORM's &lt;code&gt;.update()&lt;/code&gt; in a loop is N statements; a single &lt;code&gt;UPDATE ... WHERE id = ANY($1)&lt;/code&gt; is one. The difference compounds fast on large batches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ON CONFLICT&lt;/code&gt; (upsert) and &lt;code&gt;RETURNING&lt;/code&gt;&lt;/strong&gt; — supported by some ORMs, but often with a narrower API than the SQL clause itself allows, especially bulk upserts (&lt;code&gt;ON CONFLICT DO UPDATE&lt;/code&gt; across many rows at once) and the newer &lt;code&gt;OLD&lt;/code&gt;/&lt;code&gt;NEW&lt;/code&gt; aliases in &lt;code&gt;RETURNING&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SKIP LOCKED&lt;/code&gt; for queue-style workloads&lt;/strong&gt; — the standard pattern for letting multiple workers pull from the same table without blocking on each other's locked rows. Most ORM query builders have no vocabulary for it at all, so it's raw SQL from the start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial and expression indexes&lt;/strong&gt; — PostgreSQL can use indexes defined with a &lt;code&gt;WHERE&lt;/code&gt; clause or an expression, but the generated SQL still needs to satisfy the conditions that make those indexes usable. An ORM won't necessarily make that obvious from the application code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSONB queries with GIN indexes&lt;/strong&gt; — the operators that make these fast (&lt;code&gt;@&amp;gt;&lt;/code&gt;, &lt;code&gt;?&lt;/code&gt;, &lt;code&gt;#&amp;gt;&amp;gt;&lt;/code&gt;) are Postgres-specific and rarely have first-class ORM support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Row-level and advisory locking&lt;/strong&gt; — &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;, &lt;code&gt;pg_advisory_lock&lt;/code&gt;, and friends are concurrency primitives most ORMs expose thinly or not at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reading &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; output&lt;/strong&gt; — no ORM will do this for you. It's the one skill that stays entirely yours no matter which tool you pick.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this means avoid the ORM for these cases. It means know, ahead of time, that you'll drop to raw SQL here, and treat that as normal rather than a sign something went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is your ORM a black box? It depends which one
&lt;/h2&gt;

&lt;p&gt;So what should you actually use?&lt;/p&gt;

&lt;p&gt;Not all ORMs hide the same amount, and the trade-off usually runs opposite to how much boilerplate they remove:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Abstraction&lt;/th&gt;
&lt;th&gt;SQL visibility&lt;/th&gt;
&lt;th&gt;Best fit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prisma&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;CRUD-heavy product development&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeORM&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Traditional TypeScript applications, decorator-based models&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sequelize&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Mature Node.js codebases already built around it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drizzle&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Type-safe apps that still want SQL-shaped queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pg&lt;/code&gt; / &lt;code&gt;node-postgres&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;SQL-heavy code and hot paths&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There's no winner in that table on purpose. A team shipping CRUD-heavy internal tools benefits enormously from Prisma's or Sequelize's ergonomics and rarely hits the edges above. A team running a write-heavy, high-traffic public API needs to know exactly which SQL is running on every hot path, and might be better served by Drizzle or hand-written queries where it matters most. Plenty of production codebases mix approaches deliberately: an ORM for the CRUD majority, raw SQL for the handful of queries that are actually hot.&lt;/p&gt;

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

&lt;p&gt;The ORM isn't the problem. Forgetting that an ORM is generating database operations is the problem. It doesn't remove the need to understand what Postgres is doing, it just moves the moment you're forced to learn it, from day one of the project to the incident where your database is drowning in unnecessary queries because the ORM issued forty small &lt;code&gt;UPDATE&lt;/code&gt; statements instead of one bulk operation.&lt;/p&gt;

&lt;p&gt;Use the ORM for what it's good at: the 80% of your code that's routine CRUD, type-safe and fast to write. But keep query logging on, read the SQL your ORM generates for anything on a hot path, and read the actual migration SQL before it hits production, not just the schema diff. The ORM is a very good translator. It is still worth knowing enough of the language yourself to catch it when it gets a sentence wrong.&lt;/p&gt;

</description>
      <category>orm</category>
      <category>postgres</category>
      <category>database</category>
      <category>backenddevelopment</category>
    </item>
    <item>
      <title>The Dual-Write Problem: Why "Update the DB, Then Publish an Event" Is Broken</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Sat, 29 Aug 2026 03:28:24 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/the-dual-write-problem-why-update-the-db-then-publish-an-event-is-broken-2008</link>
      <guid>https://dev.to/jatinjainsaraf/the-dual-write-problem-why-update-the-db-then-publish-an-event-is-broken-2008</guid>
      <description>&lt;p&gt;An order gets saved to Postgres. The next line of code publishes an "order created" event to a message broker, so the payments service can charge the customer, shipping can start prepping a label, and notifications can send a confirmation email. That's the whole design. It works in every test, in staging, and in production, for months.&lt;/p&gt;

&lt;p&gt;Then one day the process gets killed between those two lines. Deploy rolled a pod mid-request. The broker connection timed out for four seconds during a network blip. A GC pause pushed the request past a load balancer timeout and the client retried against a different instance while the first one was still finishing up. The order is sitting in the database, fully committed, real, billable. Nobody downstream ever hears about it. No exception was thrown. No alert fired. The system looks healthy. A customer just placed an order that will never ship.&lt;/p&gt;




&lt;h3&gt;
  
  
  Why You Can't Just Wrap It in a Transaction
&lt;/h3&gt;

&lt;p&gt;The instinct is to reach for a transaction: begin, insert the order, publish the event, commit. That doesn't work, and it's worth being precise about why, because the reason is architectural, not a missing try/catch.&lt;/p&gt;

&lt;p&gt;A database transaction gives you atomicity over one resource: the database. Postgres can guarantee the order row either exists or doesn't, with nothing in between visible to other readers. A message broker (Kafka, RabbitMQ, SQS) is a &lt;em&gt;separate system&lt;/em&gt; with its own commit protocol, its own durability guarantees, and no shared transaction coordinator with your database. There's no operation that atomically says "commit this Postgres row and this Kafka message together, or neither." You are making two independent network calls to two independent systems, and between them there is always a window where one has succeeded and the other hasn't.&lt;/p&gt;

&lt;p&gt;Move the event publish before the database commit and you get the opposite failure: the event goes out, downstream services start acting on an order that doesn't exist yet, and then the database write fails (constraint violation, connection drop, whatever) and now payments has charged a card for an order that was never actually created. Order doesn't matter. Two independent writes to two independent systems, with no atomicity across the boundary, will eventually diverge no matter which one goes first. This is the dual-write problem, and it's not a bug in your code, it's a gap in what a database transaction can promise you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters more than it looks like it should:&lt;/strong&gt; this isn't a rare edge case you can accept as background noise. Every deploy is a process restart. Every autoscaling event is new pods coming up while old ones drain mid-request. Every network blip between your service and your broker is a window for this to happen. At low traffic it might occur once a month and get written off as "weird, must've been a fluke." At real scale, with thousands of writes a minute, this gap fires constantly, and it fires exactly during the conditions you're least equipped to notice it: deploys and incidents, when your attention is already somewhere else.&lt;/p&gt;




&lt;h3&gt;
  
  
  Two-Phase Commit: The Theoretically Correct Answer Nobody Uses
&lt;/h3&gt;

&lt;p&gt;The textbook fix for "atomically commit across two systems" is Two-Phase Commit (2PC). A coordinator asks every participant (the database, the broker) to prepare, meaning "lock this resource and confirm you &lt;em&gt;can&lt;/em&gt; commit, but don't commit yet." Once every participant says yes, the coordinator tells everyone to commit for real. If any participant says no, the coordinator tells everyone to roll back.&lt;/p&gt;

&lt;p&gt;It's a real protocol with a real correctness proof, and it's almost never what production systems actually use for this problem, for reasons that show up the moment you operate it instead of just reading about it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's blocking.&lt;/strong&gt; Once a participant says "yes, I can commit," it has to hold that lock until the coordinator's final decision arrives. If the coordinator crashes after collecting votes but before sending the commit decision, every participant is stuck holding locks indefinitely, waiting for a coordinator that might not come back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Most message brokers don't implement the participant side of the protocol at all.&lt;/strong&gt; Kafka has no native 2PC participant role. You'd be building and maintaining that coordination layer yourself, on top of a system that was never designed to expose it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't survive network partitions gracefully.&lt;/strong&gt; The entire protocol assumes the coordinator can eventually reach every participant. A partition during the decision phase leaves things in exactly the indeterminate, locked state the protocol was supposed to prevent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;2PC is the right answer to a narrower question: coordinating multiple &lt;em&gt;databases&lt;/em&gt; that all speak a compatible protocol, in a controlled environment where you own the failure modes. It's the wrong tool for "coordinate my database with my message broker," which is the shape of the dual-write problem almost everyone actually hits.&lt;/p&gt;




&lt;h3&gt;
  
  
  The Outbox Pattern: Make the Second Write Boring
&lt;/h3&gt;

&lt;p&gt;The pattern that actually gets used starts from a reframe: stop trying to make two different systems commit atomically, and instead make the &lt;em&gt;second write&lt;/em&gt; something the &lt;em&gt;same&lt;/em&gt; database transaction can own.&lt;/p&gt;

&lt;p&gt;Instead of publishing to the broker directly, you write the event as a row in an &lt;code&gt;outbox&lt;/code&gt; table, in the exact same transaction as the order insert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ord_123'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'cust_456'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'created'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;aggregate_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'ord_123'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'order.created'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{"order_id":"ord_123","total_cents":4999}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now atomicity is trivial, because both writes are ordinary rows in the database you already have transactional guarantees for. Either both rows exist or neither does. There is no window where the order exists but its event doesn't, because "the event exists" now just means "a row in the same table transaction says so."&lt;/p&gt;

&lt;p&gt;A separate relay process, running continuously, polls the outbox table for unpublished rows (or reads Postgres's write-ahead log directly via logical replication, which is how tools like Debezium do it without polling), publishes each one to the real message broker, and marks it published. If the relay crashes, it resumes from the last unpublished row on restart. If the broker is briefly unavailable, the events just sit in the table until it recovers. The order write itself never blocks on the broker being up at all.&lt;/p&gt;

&lt;p&gt;The trade-off is honest, not hidden: downstream consumers now see events after a short delay (however often the relay polls, typically sub-second to a few seconds), and they can receive the same event more than once if the relay publishes successfully but crashes before marking the row as sent. That second part is why every consumer of these events has to be idempotent, not because it's good hygiene in the abstract, but because at-least-once delivery is the actual guarantee the outbox gives you, and "exactly once" isn't achievable without it.&lt;/p&gt;




&lt;h3&gt;
  
  
  Sagas: The Same Problem, Stretched Across Multiple Services
&lt;/h3&gt;

&lt;p&gt;The outbox pattern solves atomicity for one write plus one event. A saga is what you reach for when the operation itself spans multiple services, each with its own local database, and there's no way to wrap the whole thing in a transaction even in principle: reserve inventory, charge the payment, create the shipment. Three services, three databases, one logical operation.&lt;/p&gt;

&lt;p&gt;A saga runs this as a sequence of local transactions, each one committing on its own, with a &lt;strong&gt;compensating action&lt;/strong&gt; defined for each step in case a later step fails:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Inventory service reserves the item. Commits locally.&lt;/li&gt;
&lt;li&gt;Payment service charges the card. Commits locally.&lt;/li&gt;
&lt;li&gt;Shipping service creates the shipment. If this fails...&lt;/li&gt;
&lt;li&gt;...run the compensations in reverse: refund the payment, release the inventory reservation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing here is atomic in the ACID sense. There's a real window, between steps 1 and 3, where inventory is reserved and no shipment exists yet. A saga doesn't hide that window, it makes it explicit and time-bounded, and gives you a defined path back to a consistent state if the last step doesn't complete. That's a fundamentally different consistency model than a database transaction (eventual, with defined compensations, instead of immediate and atomic), and pretending otherwise is where sagas go wrong in practice: teams build the happy path, skip writing the compensating actions because "that won't really happen," and then an incident forces someone to write the refund logic live, under pressure, for a case they never tested.&lt;/p&gt;

&lt;p&gt;Each step publishing its "I'm done" or "I failed" signal is itself a dual-write problem at a smaller scale, which is why saga implementations lean on the outbox pattern internally for each step, rather than being a separate mechanism from it. The two patterns compose: outbox solves atomic local write-plus-event, sagas solve the multi-step orchestration built on top of that primitive.&lt;/p&gt;




&lt;h3&gt;
  
  
  What This Actually Demonstrates
&lt;/h3&gt;

&lt;p&gt;None of this is about picking the "advanced" pattern to look sophisticated. It's about recognizing that "write to the database, then tell everyone else" is not one operation, it's two, and the honest response to that is either to make the second write ride inside the first one's transaction (Outbox) or to make the multi-step version of that gap explicit and recoverable (Sagas) — not to assume the gap won't matter until an incident proves otherwise.&lt;/p&gt;

</description>
      <category>systemdesign</category>
      <category>distributedsystems</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Elasticsearch Isn't Dead. You Probably Don't Need It</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Fri, 21 Aug 2026 01:34:27 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/elasticsearch-isnt-dead-you-probably-dont-need-it-47m</link>
      <guid>https://dev.to/jatinjainsaraf/elasticsearch-isnt-dead-you-probably-dont-need-it-47m</guid>
      <description>&lt;h1&gt;
  
  
  Elasticsearch Isn't Dead. You Probably Don't Need It.
&lt;/h1&gt;

&lt;p&gt;Every team hits the same moment. Search gets slow, someone says "we need Elasticsearch," and two weeks later there's a new cluster, a sync pipeline, and a Slack channel called &lt;code&gt;#search-is-down&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the case for not doing that. PostgreSQL's built-in full-text search handles the kind of workload many teams actually have, and it does it without adding a second database to the architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real cost isn't the cluster, it's the copy
&lt;/h2&gt;

&lt;p&gt;Adding Elasticsearch doesn't add a search feature. It adds a second brain that has to keep agreeing with your first one.&lt;/p&gt;

&lt;p&gt;Your data lives in Postgres. Now it also has to live in Elasticsearch, duplicated, reshaped into documents, kept current through a queue or a change-data-capture pipeline or a reindex job someone wrote in a hurry two years ago. Every insert, update, and delete in Postgres needs a matching write on the other side. That sync layer is where the real cost lives, not in cluster fees:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A customer updates their email. Postgres has the new one. Elasticsearch still returns the old one for six hours because the CDC consumer fell behind.&lt;/li&gt;
&lt;li&gt;A row gets deleted in Postgres. The reindex job crashed last Tuesday, so it still shows up in search, and support gets a ticket about a ghost customer.&lt;/li&gt;
&lt;li&gt;Someone runs a backfill migration, forgets the matching backfill in Elasticsearch, and search results quietly diverge from the database for a month before anyone notices.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are Elasticsearch bugs. They're the tax you pay for keeping two representations of the same data that don't share a transaction boundary. Postgres full-text search skips this entirely, because there's nothing to sync. The searchable representation lives in the same database and is maintained transactionally with the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Postgres actually gives you: tsvector and tsquery
&lt;/h2&gt;

&lt;p&gt;Full-text search in Postgres is built on two pieces: &lt;code&gt;tsvector&lt;/code&gt;, which turns text into a normalized, searchable format, and &lt;code&gt;tsquery&lt;/code&gt;, which turns a search string into something you can match against it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'PostgreSQL indexes make searches fast'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- 'fast':5 'index':2 'make':3 'postgresql':1 'search':4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;to_tsvector&lt;/code&gt; normalizes the text, removes stopwords according to the text-search configuration (here "make" survives, it isn't one), and applies stemming where the configured dictionary supports it. That's why a search for &lt;code&gt;search&lt;/code&gt; can match text containing &lt;code&gt;searching&lt;/code&gt;, the same behavior that makes Elasticsearch feel necessary in the first place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'PostgreSQL indexes make searches fast'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'search &amp;amp; index'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@@&lt;/code&gt; operator matches a &lt;code&gt;tsvector&lt;/code&gt; against a &lt;code&gt;tsquery&lt;/code&gt;. That's full-text matching as a native Postgres operator, not a separate search service. It doesn't score relevance on its own, that's what &lt;code&gt;ts_rank&lt;/code&gt; is for below.&lt;/p&gt;

&lt;p&gt;For a real search box, &lt;code&gt;to_tsquery&lt;/code&gt; is less useful than it looks, because it throws a syntax error on ordinary user input like an unbalanced quote or a trailing &lt;code&gt;&amp;amp;&lt;/code&gt;. &lt;code&gt;websearch_to_tsquery&lt;/code&gt; is the better default: it accepts web-search-style syntax (quoted phrases, &lt;code&gt;-exclude&lt;/code&gt;, &lt;code&gt;or&lt;/code&gt;) and never rejects plain text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;websearch_to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reach for &lt;code&gt;to_tsquery&lt;/code&gt; when you control the query syntax yourself. Reach for &lt;code&gt;websearch_to_tsquery&lt;/code&gt; when the query comes from a search box.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that makes it production-ready: GIN indexes
&lt;/h2&gt;

&lt;p&gt;If you compute the &lt;code&gt;tsvector&lt;/code&gt; during every query, Postgres has to process the candidate rows to build those vectors on the fly, fine for a thousand rows, a real cost at ten million. For data that's searched regularly, storing the vector and indexing it with a GIN index (Generalized Inverted Index) avoids that repeated work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;search_vector&lt;/span&gt; &lt;span class="n"&gt;tsvector&lt;/span&gt;
  &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;articles_search_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;search_vector&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;GENERATED ALWAYS AS ... STORED&lt;/code&gt; column is the detail that matters in production. Postgres computes it whenever the underlying row is inserted or updated, and the GIN index is maintained as part of the same database transaction, the same way any index is maintained when its underlying column changes. There's no separate consumer to fall behind and no second system that can temporarily disagree with the row.&lt;/p&gt;

&lt;p&gt;A GIN index works like the index at the back of a textbook. Instead of scanning every page for the word "vacuum," you jump straight to the pages listed under V. Postgres does the same: instead of scanning every row's &lt;code&gt;tsvector&lt;/code&gt;, it jumps straight to the rows containing your search terms.&lt;/p&gt;

&lt;p&gt;Worth saying plainly: that index isn't free. It costs write overhead on every insert and update, and it costs disk, same as any index does. The difference isn't zero cost versus some cost, it's an index cost you were always going to pay somewhere versus that same index cost plus a whole second system to keep in sync. One cost, not two.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ts_rank&lt;/code&gt; scores matches by relevance, giving you the ranking step you'd otherwise reach for a search engine to provide. The query runs inside Postgres, alongside the rest of your relational data, without a network hop to a separate search cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filtering is where a bolted-on search engine shows its seams
&lt;/h2&gt;

&lt;p&gt;Most real search features aren't just "find text," they're "find text, then filter by category, status, or whatever's live right now." If Elasticsearch is populated asynchronously from Postgres, that filter is only as current as the index. A row can go inactive in Postgres while the search index still considers it active, for however long the sync pipeline lags. In Postgres, the filter and the full-text predicate run against the same transactional dataset in the same &lt;code&gt;WHERE&lt;/code&gt; clause. There's no separate search index that can lag behind the row, because the filter and the full-text predicate operate on the same Postgres data.&lt;/p&gt;

&lt;p&gt;That single detail, an asynchronously maintained index lagging the source of truth, is usually the thing that quietly breaks in production long after the Elasticsearch launch party is over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Elasticsearch still earns its keep
&lt;/h2&gt;

&lt;p&gt;This isn't "Elasticsearch is bad." It's "most teams adopted it for a problem they didn't have."&lt;/p&gt;

&lt;p&gt;Elasticsearch is worth reaching for when you need fuzzy matching and aggressive autocomplete beyond what &lt;code&gt;pg_trgm&lt;/code&gt; comfortably covers, sophisticated relevance tuning, large-scale aggregations and faceting, distributed search across genuinely large datasets, log and observability workloads, or a search workload you deliberately want isolated from your transactional database. Any one of those is a real reason. None of them are "search felt slow once."&lt;/p&gt;

&lt;p&gt;And Postgres full-text search isn't a drop-in replacement for all of that. If your product depends on typo tolerance, heavy autocomplete, deep linguistic analysis, or search-specific aggregations at real scale, the tradeoff changes quickly, and that's exactly when Elasticsearch's cost starts paying for itself.&lt;/p&gt;

&lt;p&gt;Postgres does cover more of that ground than people expect, though, particularly the typo tolerance part. &lt;code&gt;pg_trgm&lt;/code&gt; handles a different class of problem from full-text search: finding strings that are similar even when the spelling isn't exact, which is what powers a lot of "did you mean" and fuzzy-match behavior.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;pg_trgm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;articles_title_trgm_idx&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="n"&gt;gin_trgm_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="s1"&gt;'elastcsearch'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'elastcsearch'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full-text search matches words and lexemes. &lt;code&gt;pg_trgm&lt;/code&gt; matches approximate string similarity. Elasticsearch earns its place when you need substantially more search infrastructure than either of those provides.&lt;/p&gt;

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

&lt;p&gt;This was never really "Postgres vs. Elasticsearch." It's "don't add a second system until you've established that Postgres isn't enough."&lt;/p&gt;

&lt;p&gt;Start with Postgres. Add full-text search. Add a GIN index. Add &lt;code&gt;pg_trgm&lt;/code&gt; if you need fuzzy matching. Measure. Introduce Elasticsearch when the workload actually demands it, not when search first feels slow.&lt;/p&gt;

&lt;p&gt;For many teams, that measurement never gets there. The search bar was never the hard part. Keeping two systems telling the same story was.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>elasticsearch</category>
      <category>fulltextsearch</category>
      <category>databasearchitecture</category>
    </item>
    <item>
      <title>Why Your Docker Image Is 3GB and Nobody Noticed Until the Deploy Timed Out</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Wed, 19 Aug 2026 17:41:42 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/why-your-docker-image-is-3gb-and-nobody-noticed-until-the-deploy-timed-out-41gj</link>
      <guid>https://dev.to/jatinjainsaraf/why-your-docker-image-is-3gb-and-nobody-noticed-until-the-deploy-timed-out-41gj</guid>
      <description>&lt;h1&gt;
  
  
  Why Your Docker Image Is 3GB and Nobody Noticed Until the Deploy Timed Out
&lt;/h1&gt;

&lt;p&gt;A service that used to deploy in ninety seconds now takes six minutes. Nobody changed the code that day. The base image hasn't changed, &lt;code&gt;docker push&lt;/code&gt; is uploading to the same registry it always has, and the CI runner is the same size it always has been. What changed is that the image itself quietly grew, build after build, revision after revision, until one day the push step timed out and someone finally looked. Nothing broke on purpose. The image just never stopped getting heavier, and nobody was watching the number.&lt;/p&gt;




&lt;h3&gt;
  
  
  An Image Is a Stack of Layers, Not a Snapshot
&lt;/h3&gt;

&lt;p&gt;A Docker image isn't one file, it's a stack of read-only layers, one per instruction in the Dockerfile that changes the filesystem: a &lt;code&gt;RUN&lt;/code&gt;, a &lt;code&gt;COPY&lt;/code&gt;, an &lt;code&gt;ADD&lt;/code&gt;. Each layer is a diff against the one below it, and at runtime the container's filesystem is presented as the combination of all those layers through a union filesystem, with a thin writable layer on top for the container itself. The layers stay separate on disk; nothing flattens them into one file. Crucially, a layer never shrinks once written. If a &lt;code&gt;RUN apt-get install&lt;/code&gt; layer downloads 400MB of packages and a later &lt;code&gt;RUN apt-get clean&lt;/code&gt; layer deletes the package cache, the image doesn't get 400MB smaller. The delete happens in a new layer on top; the old layer with the 400MB still sits underneath it, and that layer remains part of the image that nodes need to have available. The deleted file disappears from the filesystem you actually see when the container runs, but the bytes are still physically present in the image and still contribute to its size. If a registry or node doesn't already have that layer, those bytes have to be transferred during a push or pull.&lt;/p&gt;

&lt;p&gt;Think of it like a suitcase you never fully unpack between trips. You don't take everything out and repack from scratch, you just add what you need for this trip on top of what's already in there. Take something out and it's not gone, it's still in the suitcase, just buried under a note that says "ignore this." The suitcase only gets heavier. That's a Docker image with unpruned layers: every stale cache, every intermediate build tool, every "temporary" file that got &lt;code&gt;rm&lt;/code&gt;'d in a later step is still physically present, just marked as superseded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this shows up in production:&lt;/strong&gt; a base image with a full OS toolchain (Ubuntu with build-essential, a full Node.js image instead of a smaller variant) starts you 500MB to 1GB heavier before your application code exists at all. Every dependency installed and not cleaned up in the same layer, every &lt;code&gt;COPY . .&lt;/code&gt; that grabs &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;.git&lt;/code&gt;, and test fixtures because there's no &lt;code&gt;.dockerignore&lt;/code&gt;, adds weight that never comes back off. None of it fails a build. It just makes every image bigger than the last one, silently, until push and pull times are the bottleneck. A rough breakdown of how an image gets to 3GB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Base image                900 MB
Build dependencies        700 MB   (compilers, headers, dev packages)
node_modules               500 MB
Source and test files      200 MB
Deleted-but-retained data  700 MB  (old caches, files removed in a later layer)
------------------------------------
Total                     ~3.0 GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No single layer looks unreasonable on its own. It's the accumulation across all of them, plus the ones that should have been discarded but structurally can't be, that adds up.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Bad Layer Ordering Makes Every Build Slower
&lt;/h3&gt;

&lt;p&gt;Layer caching is supposed to make builds fast: Docker walks the Dockerfile instruction by instruction and reuses the cached result for each one, until it hits the first instruction whose cache is invalid, either because the instruction itself changed or because a file it depends on changed. From that instruction onward, every remaining layer has to be rebuilt, even if most of them don't actually depend on what changed. That's the entire point of the layer model, and it only works if the Dockerfile is ordered so that the things which change least often come first, and the things that change on every commit come last.&lt;/p&gt;

&lt;p&gt;The single most common mistake is &lt;code&gt;COPY . .&lt;/code&gt; before the dependency-install step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Bad: any file change invalidates the install layer&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every commit touches some file in the repo, so that &lt;code&gt;COPY&lt;/code&gt; layer's cache is invalidated on every build, and &lt;code&gt;npm install&lt;/code&gt; right after it has to rerun from a cold cache every time, even when &lt;code&gt;package.json&lt;/code&gt; didn't change. Reordering it fixes the problem directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Better: only a change to package*.json invalidates the install layer&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now changing &lt;code&gt;src/foo.js&lt;/code&gt; invalidates only the final &lt;code&gt;COPY&lt;/code&gt;, and the dependency-install layer above it stays cached. A build that might otherwise take fifteen seconds because nothing dependency-related moved instead can take three minutes on the bad version, on every revision, forever, because the Dockerfile put the wrong instruction first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this shows up in production:&lt;/strong&gt; this is the mechanism behind "deploys used to be fast and now they're not," with no code change to point at. It's not the application getting slower, it's the build losing its cache on every run because of layer ordering, on top of an image that's also grown heavier over time, so both the build step and the push/pull step degrade independently and get blamed on each other.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Registry Doesn't Forget Either
&lt;/h3&gt;

&lt;p&gt;The same "nothing shrinks unless someone tells it to" problem exists one level up, in the artifact registry itself. Every build that pushes a new tag, &lt;code&gt;latest&lt;/code&gt;, a commit SHA, a version number, adds a new manifest and a new set of layer blobs to the registry. Layers that are byte-identical get deduplicated by content hash, which is why the registry doesn't grow linearly with every push, but every layer that's actually different (a new dependency version, a new base image patch, a rebuilt application layer) is new data that has to be stored, and old images don't get deleted just because a newer one exists.&lt;/p&gt;

&lt;p&gt;A registry doesn't necessarily know which images are no longer useful. Unless you give it retention rules or clean them up yourself, old manifests and their unique layer blobs just accumulate: every feature-branch build, every hotfix, every image built by a CI run that never got cleaned up, all still sitting there, still counted against storage. A registry with a two-year-old image nobody has pulled in eighteen months isn't just wasted storage, it's an image nobody is auditing for CVEs anymore, sitting right next to the one that's actually in production, indistinguishable to anyone browsing tags without a naming convention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this shows up in production:&lt;/strong&gt; registry storage bills climbing with no obvious cause, and CI jobs or registry maintenance operations getting slower or more expensive as a repository accumulates large numbers of manifests and unique layer blobs. A single &lt;code&gt;docker pull&lt;/code&gt; for a specific tag isn't slowed down by unrelated stale tags sitting elsewhere in the repository, it only fetches the layers that tag's manifest actually references, but repository-wide operations, listing tags, running garbage collection, scanning for vulnerabilities across everything stored, all get heavier as the pile grows. The fix isn't a bigger registry plan, it's a retention policy: expire untagged manifests and feature-branch tags after N days, keep only the last M builds per branch, and let content-addressable deduplication do the rest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Measure Before You Guess
&lt;/h3&gt;

&lt;p&gt;Before reordering a Dockerfile or swapping a base image, find out what's actually taking up space. Two commands answer that directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker image &lt;span class="nb"&gt;history &lt;/span&gt;myapp:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lists every layer in the image with its size, in order, so you can see exactly which instruction added the most weight.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker image inspect myapp:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gives the full layer manifest and metadata, useful for scripting size checks in CI. For a closer look at what's actually sitting inside the largest layers, a tool like &lt;a href="https://github.com/wagoodman/dive" rel="noopener noreferrer"&gt;&lt;code&gt;dive&lt;/code&gt;&lt;/a&gt; walks the filesystem contents layer by layer, which is usually faster than guessing from the Dockerfile alone. Run &lt;code&gt;docker image history&lt;/code&gt; first: it takes thirty seconds and tells you whether the problem is the base image, an unpruned dependency cache, or the build context, before you change anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Actually Fixes It
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Order the Dockerfile by change frequency.&lt;/strong&gt; Dependency manifests (&lt;code&gt;package.json&lt;/code&gt;, &lt;code&gt;requirements.txt&lt;/code&gt;) get copied and installed first, source code gets copied last. That one reordering is usually the single biggest build-time win available.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use multi-stage builds.&lt;/strong&gt; Build in one stage with the full toolchain, compilers, dev dependencies, and copy only the compiled output into a clean final stage. The build tools never make it into the image that ships. A simplified Node example, for a service that produces a self-contained &lt;code&gt;dist&lt;/code&gt; directory:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22-slim&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/dist ./dist&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "dist/index.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final image only contains what &lt;code&gt;COPY --from=build&lt;/code&gt; explicitly pulls forward: the compiled output and production dependencies. The compiler, dev dependencies, and source files from the build stage never exist in the shipped image at all.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pick a smaller runtime image where it makes sense.&lt;/strong&gt; &lt;code&gt;-slim&lt;/code&gt;, distroless, or Alpine variants are often the difference between a 900MB image and a 90MB one before any application code is added, but they're not a free win in every case: Alpine's musl libc can surface compatibility issues with some native dependencies, so weigh the size/security gain against your actual runtime's tolerance for it rather than defaulting to it blindly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write a real &lt;code&gt;.dockerignore&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;.git&lt;/code&gt;, test fixtures, and local env files should never be in the build context in the first place. This keeps the image smaller directly, and it also keeps irrelevant files from ever reaching a &lt;code&gt;COPY&lt;/code&gt; layer and invalidating its cache for no reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set a registry retention policy.&lt;/strong&gt; Expire untagged and stale feature-branch images automatically instead of relying on someone remembering to clean up. Most registries (ECR, Artifact Registry, GitHub Container Registry, Harbor) support this natively.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are exotic. They're three separate mechanisms with the same operational lesson: unused data doesn't disappear unless you design for it or clean it up on purpose. A service that deploys in six minutes instead of ninety seconds usually isn't a mystery. It's a suitcase that's been repacked on top of itself for a year, and nobody's taken anything out.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>performance</category>
    </item>
    <item>
      <title>Why Postgres and Cassandra Made Opposite Bets on Storage Engines</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Sat, 15 Aug 2026 09:54:24 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/why-postgres-and-cassandra-made-opposite-bets-on-storage-engines-m80</link>
      <guid>https://dev.to/jatinjainsaraf/why-postgres-and-cassandra-made-opposite-bets-on-storage-engines-m80</guid>
      <description>&lt;h1&gt;
  
  
  Why Postgres and Cassandra Made Opposite Bets on Storage Engines
&lt;/h1&gt;

&lt;p&gt;A write-heavy ingestion table starts slow on Postgres. Not immediately, but a few weeks in: inserts that used to take a millisecond now take ten, autovacuum is perpetually behind, and &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; shows time going into index maintenance nobody remembers configuring. The instinct is to blame the schema, or the hardware, or "Postgres doesn't scale." None of those are quite right. The real answer is a decision Postgres made in the 1990s, long before this table existed: it keeps its indexes as B-Trees, and every one of them has to stay sorted, on every write.&lt;/p&gt;




&lt;h3&gt;
  
  
  Every Write Has to Land Somewhere
&lt;/h3&gt;

&lt;p&gt;Strip a database down to its storage engine and the job is always the same: take a write, put it on disk in a shape that makes future reads fast, and don't lose it. There are two dominant answers to how to do that, and most databases you've heard of lean on one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B-Trees&lt;/strong&gt; keep some part of the data sorted on disk, in place, at all times. MySQL's InnoDB and SQLite go all the way: the table itself is a B-Tree, keyed by its primary key or rowid, so the table and its main index are the same structure (a "clustered index"). Postgres is more layered: the table (the heap) is an unordered file with no sort order of its own, rows just go wherever the free space map says there's room, but every index on that table, including the primary key, is a separate B-Tree that has to stay sorted and has to be updated on every write that touches an indexed column.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LSM-Trees&lt;/strong&gt;, log-structured merge trees (Cassandra, RocksDB, LevelDB, and CockroachDB's Pebble storage engine), take the opposite approach everywhere: a write never touches its final sorted position immediately. It gets appended to whatever's currently open, and getting everything back into sorted order is a job for later, done in the background, in bulk.&lt;/p&gt;

&lt;p&gt;That fork, sorted-in-place versus sorted-later, explains almost every practical difference in how these two families of databases behave under load.&lt;/p&gt;

&lt;h3&gt;
  
  
  B-Trees: Pay at Write Time, Save at Read Time
&lt;/h3&gt;

&lt;p&gt;Think of each B-Tree index as a filing cabinet that's always perfectly alphabetized. Every entry goes directly into its correct folder, in its correct position, the moment it arrives. Finding anything later is fast and predictable, you walk straight to the folder. But filing it correctly in the first place means locating the right spot, possibly shifting other entries out of the way, and writing to a specific place on disk rather than just the next free spot.&lt;/p&gt;

&lt;p&gt;Here's what a Postgres insert actually does. The row itself is appended to the heap, close to a sequential write, wherever the free space map finds room. But every index on that table, the primary key, any unique constraint, any column you've indexed for lookups, is a B-Tree, and each one needs a new entry written to its correct leaf page, wherever that page happens to sit on disk. Two indexes on a table means one heap append plus two separate random writes, every single insert.&lt;/p&gt;

&lt;p&gt;Updates are the more expensive case, and this is where MVCC changes the arithmetic. Postgres never overwrites a row in place: an &lt;code&gt;UPDATE&lt;/code&gt; marks the old row version dead and writes an entirely new version elsewhere in the heap. That means an &lt;code&gt;UPDATE&lt;/code&gt; costs everything an &lt;code&gt;INSERT&lt;/code&gt; costs, a new heap entry, plus a new entry in every index, plus it leaves a dead tuple behind that autovacuum eventually has to clean up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this shows up in production:&lt;/strong&gt; early in a table's life, its hot pages and index pages fit comfortably in &lt;code&gt;shared_buffers&lt;/code&gt;, so those "random" writes are really just writes to RAM, flushed to disk lazily and cheaply. Once the table and its indexes outgrow memory, every insert or update has a real chance of touching an index page that isn't cached, turning a cheap write into a disk seek, on every indexed column, on every write. Add autovacuum needing to revisit the dead tuples MVCC leaves scattered across heap pages, and you get the exact symptom this post opened with: a table that used to be fast getting steadily slower with no code change, just growth, and just more indexes to maintain per write.&lt;/p&gt;

&lt;h3&gt;
  
  
  LSM-Trees: Write First, Sort Later
&lt;/h3&gt;

&lt;p&gt;An LSM-Tree handles the same problem by refusing to do that work up front. Picture an inbox tray instead of a filing cabinet: every new entry just gets dropped on top of the pile. Filing is instant, because there's no filing, you're not finding anything, you're just adding to a stack. Periodically, someone takes the whole pile, sorts it properly, and merges it into the already-sorted sections below it.&lt;/p&gt;

&lt;p&gt;Mechanically: writes go into an in-memory structure (a memtable) and are also logged to a commit log for durability, both sequential operations. When the memtable fills up, it's flushed to disk as an immutable sorted file (an SSTable). Over time you accumulate many of these sorted files, and a background process called &lt;strong&gt;compaction&lt;/strong&gt; merges them together, discarding values that have been overwritten or deleted along the way.&lt;/p&gt;

&lt;p&gt;Deletes are handled the same indirect way updates are avoided: a delete doesn't remove anything immediately, it writes a &lt;strong&gt;tombstone&lt;/strong&gt;, a marker saying "this key is deleted as of this timestamp." The actual old data only disappears once compaction physically rewrites the SSTables that contained it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this shows up in production:&lt;/strong&gt; writes are cheap and consistently fast, because they're always sequential appends, regardless of how big the dataset gets or where the "right place" for the data would eventually be. That's why Cassandra-style databases are the default reach for write-heavy workloads: time-series ingestion, event logs, metrics pipelines. The cost is deferred, not eliminated. A read might have to check the memtable and several SSTables before it can be sure it has the latest version of a key, that's &lt;strong&gt;read amplification&lt;/strong&gt;, and it's the mirror image of the index-maintenance cost a B-Tree pays on every write. Compaction itself is a real, ongoing background cost too: it consumes disk I/O and CPU that has to be budgeted for, and if compaction ever falls behind sustained write load, both read latency and disk usage climb, in a way that looks a lot like Postgres autovacuum falling behind, just triggered by the opposite kind of pressure.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Just Switch to Cassandra" Isn't the Fix It Sounds Like
&lt;/h3&gt;

&lt;p&gt;It's tempting to read the above and conclude LSM-Trees are strictly better for anything write-heavy. They're not, they're differently shaped, with their own failure modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read amplification is real.&lt;/strong&gt; A point lookup that's a single B-Tree traversal in Postgres might mean checking a memtable plus several SSTables in an LSM-Tree, unless bloom filters and careful compaction keep that bounded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compaction is not free.&lt;/strong&gt; It's extra write I/O (rewriting data that was already written once), and a compaction strategy mismatched to the workload (size-tiered vs. leveled) can make total write cost in an LSM-Tree worse than a B-Tree's, not better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tombstones linger.&lt;/strong&gt; Until compaction actually removes the old versions and their tombstones, deleted data still occupies space and still gets scanned past on reads, and in Cassandra specifically, an excess of tombstones scanned in a single read can trip query-side warnings or failures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transactional guarantees differ.&lt;/strong&gt; Postgres gives you multi-row ACID transactions and foreign keys as first-class features because the B-Tree indexes, MVCC, and WAL were co-designed for exactly that. Most LSM-based systems trade some of that away for horizontal write scale, which is a real cost if your workload actually needs cross-row consistency, not just fast ingestion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Migrating a workload from Postgres to Cassandra to solve a write-scaling problem, without checking whether the workload also needs the guarantees Postgres was giving up in the trade, is how you end up rebuilding transactional consistency by hand in application code, which is a worse position than the one you started in.&lt;/p&gt;

&lt;h3&gt;
  
  
  When Each Model Actually Wins
&lt;/h3&gt;

&lt;p&gt;The honest framework isn't "which database is better," it's "which cost am I willing to pay":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Choose a B-Tree-indexed engine&lt;/strong&gt; (stay on Postgres, tune it) when reads dominate, or when transactional consistency and relational integrity across tables matter more than raw write throughput. Most application backends, most OLTP workloads, most systems where "this row is correct right now" matters, belong here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose an LSM-Tree engine&lt;/strong&gt; when the workload is write-dominated and append-heavy by nature, event streams, time-series metrics, logs, and reads can tolerate either eventual consistency or a slightly higher read cost in exchange for writes that don't degrade as the dataset grows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Or, more often than either extreme:&lt;/strong&gt; the write-heavy table causing the pain doesn't need to be in Postgres at all. Partitioning it, moving it to a purpose-built time-series or log store, or reducing the number of indexes it maintains, frequently solves the actual problem without touching the primary transactional database's engine at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Postgres isn't losing to Cassandra when a table gets slow under heavy writes. It's honoring the trade it made on day one: keep every index sorted and correct on every write, so reads stay cheap and consistent forever. Whether that's still the right trade for a specific table, and how many indexes it really needs, is a workload question, not a verdict on the database.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>architecture</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>The Codebase You Sit In Rubs Off On You</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Tue, 11 Aug 2026 17:55:26 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/the-codebase-you-sit-in-rubs-off-on-you-19nl</link>
      <guid>https://dev.to/jatinjainsaraf/the-codebase-you-sit-in-rubs-off-on-you-19nl</guid>
      <description>&lt;h1&gt;
  
  
  The Codebase You Sit In Rubs Off On You
&lt;/h1&gt;

&lt;p&gt;Spend all day at a fish market and you'll leave smelling like fish, even if you never touched one.&lt;/p&gt;

&lt;p&gt;Spend your day in a codebase full of shortcuts, and you'll start writing shortcuts too, even if nobody told you to and even if you swore you never would.&lt;/p&gt;

&lt;p&gt;That's how environments work. They rub off on you, whether you notice it or not. And in engineering, this isn't a metaphor it shows up in your git log.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lie of "it's all about discipline"
&lt;/h2&gt;

&lt;p&gt;Most engineers think their habits come from training, courses, or sheer effort. If your code is sloppy, the assumption is you didn't try hard enough. If it's clean, you must have "good discipline."&lt;/p&gt;

&lt;p&gt;That's only half true. A huge part of how you code comes from what surrounds you every day, on repeat, long before you're conscious of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The PRs you review, and the ones that get rubber-stamped instead of actually reviewed.&lt;/li&gt;
&lt;li&gt;The Slack threads where someone asks "does this really need a test?" and the answer is a shrug.&lt;/li&gt;
&lt;li&gt;The postmortems you sit through,  the ones that hunt for a person to blame versus the ones that hunt for a broken process.&lt;/li&gt;
&lt;li&gt;The teammate whose code you copy-paste from at 2am because it's the closest example you can find, bugs and all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this announces itself as a lesson. Nobody sends a Slack message saying "today I am teaching you to skip error handling." It just seeps in through repetition, the same way an accent seeps in from the people you grow up around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this actually shows up in production
&lt;/h2&gt;

&lt;p&gt;This isn't abstract. It shows up as specific, recognizable symptoms:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Silent try/catch blocks everywhere.&lt;/strong&gt; Not because anyone taught "swallow your errors," but because the first three examples of error handling you saw in that codebase did exactly that, and you pattern-matched off them without questioning it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A team where nobody writes tests for "obvious" changes.&lt;/strong&gt; Six months later, someone ships an "obvious" one-liner that takes down checkout for twenty minutes. The postmortem calls it human error. It was actually a cultural default nobody chose on purpose, it was absorbed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incident channels full of finger-pointing instead of timelines.&lt;/strong&gt; New hires learn within a week that admitting a mistake early gets you blamed, so they learn to sit on problems until they're unavoidable. That delay is the expensive part, not the original mistake.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commit messages that are just "fix," "update," "wip."&lt;/strong&gt; You'll find the one senior engineer on the team who started that pattern years ago, still committing "fix" today, and everyone quietly inherited it.&lt;/p&gt;

&lt;p&gt;Compare that to a team where the senior engineers write commit messages that explain &lt;em&gt;why&lt;/em&gt;, not just &lt;em&gt;what&lt;/em&gt;; where someone flags an edge case out loud in standup instead of letting it slide; where an incident review ends with "here's the missing guardrail" instead of "here's who missed it." Sit in that environment for six months and you'll start doing the same things, not because you decided to, but because that's what normal looks like now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "just work harder" doesn't fix this
&lt;/h2&gt;

&lt;p&gt;If your habits were purely a function of effort, then two equally hardworking engineers in different environments should converge on similar code quality. They don't. Drop the same disciplined engineer into a team that treats testing as optional, and within a year their testing habits will have eroded, not because they got lazy, but because the environment stopped rewarding the behavior and stopped modelling it.&lt;/p&gt;

&lt;p&gt;This is also why "hire good people and leave them alone" doesn't scale culture. Good habits decay under a bad environment faster than bad habits improve under a good one is slow. Osmosis runs both directions, and it runs constantly, not just during onboarding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Changing what you're surrounded by is the actual lever
&lt;/h2&gt;

&lt;p&gt;This is why the fastest way to level up often isn't another course or another book, it's deliberately changing what you're marinating in every day:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Review code from engineers better than you, even when it's not required.&lt;/strong&gt; You're not looking for bugs; you're absorbing their defaults.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sit in on incident calls outside your own team.&lt;/strong&gt; Watching how a genuinely good incident is run, calm, timestamped, blameless,  recalibrates what "normal" looks like faster than any postmortem template.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Follow people who write and talk publicly about the specific problems you want to get better at.&lt;/strong&gt; Your feed is an environment too. It rubs off exactly like your team does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose the side projects and open-source codebases you spend your evenings in as carefully as the one you're paid to work in.&lt;/strong&gt; If every codebase you touch after 6pm is a mess, don't be surprised when "mess" starts to feel acceptable at 10am.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your first team teaches you more about "how to engineer" than any style guide, wiki page, or onboarding doc ever will, whether that team is excellent or quietly broken. And it keeps teaching you, every day you stay in it, long after onboarding ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real question
&lt;/h2&gt;

&lt;p&gt;The scent of your environment always follows you into your next commit.&lt;/p&gt;

&lt;p&gt;So the question worth asking isn't "am I working hard enough?"&lt;/p&gt;

&lt;p&gt;It's "what am I letting rub off on me, and is it actually who I want to become as an engineer?"&lt;/p&gt;

</description>
      <category>engineeringculture</category>
      <category>softwareengineering</category>
      <category>careergrowth</category>
      <category>codequality</category>
    </item>
    <item>
      <title>PostgreSQL's Async I/O Engine: Why Your Sequential Scans Just Got 2-3x Faster</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Sat, 08 Aug 2026 16:23:05 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/postgresqls-async-io-engine-why-your-sequential-scans-just-got-2-3x-faster-4gli</link>
      <guid>https://dev.to/jatinjainsaraf/postgresqls-async-io-engine-why-your-sequential-scans-just-got-2-3x-faster-4gli</guid>
      <description>&lt;p&gt;For as long as PostgreSQL has existed, reading a page from disk has looked the same: a backend process calls &lt;code&gt;read()&lt;/code&gt;, the OS schedules the I/O, and the process blocks until the page comes back. Then it asks for the next one. One request, one wait, repeat.&lt;/p&gt;

&lt;p&gt;That's fine when your data is sitting in memory. It becomes a real problem on modern NVMe storage. An NVMe drive can have 32 or more I/O requests in flight at once, happily serving them in parallel. Postgres, until version 18, never took advantage of that. It asked for one page, waited for it to come back, then asked for the next. The hardware was capable of a highway; Postgres was driving it one car at a time.&lt;/p&gt;

&lt;p&gt;PostgreSQL 18 changes that with a real asynchronous I/O subsystem. Here's the mental model worth keeping: synchronous I/O is a single waiter who takes one table's order, walks it to the kitchen, stands there until it's ready, and only then goes to take the next order. Async I/O is that same waiter dropping ten orders at the kitchen window at once and picking up whichever plates are ready as they come up. Same kitchen, same waiter, dramatically less standing around.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed
&lt;/h2&gt;

&lt;p&gt;Postgres now has a pluggable I/O backend, controlled by a new &lt;code&gt;io_method&lt;/code&gt; setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;io_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;io_uring   # Linux 5.1+, lowest overhead&lt;/span&gt;
&lt;span class="py"&gt;io_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;worker      # cross-platform fallback (macOS, Windows, older Linux)&lt;/span&gt;
&lt;span class="py"&gt;io_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;sync        # pre-PG18 behavior&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;io_uring&lt;/code&gt; is the interesting one. It's a Linux kernel interface that gives Postgres two ring buffers shared with the kernel: one to submit I/O requests, one to collect completions. Submitting a batch of reads costs a single syscall instead of one syscall per page. On &lt;code&gt;worker&lt;/code&gt;, a pool of background threads makes the blocking calls on your process's behalf so your backend never has to sit and wait itself.&lt;/p&gt;

&lt;p&gt;The parameter that actually controls the payoff for read-heavy workloads is &lt;code&gt;effective_io_concurrency&lt;/code&gt;. Before PG18 this only affected bitmap heap scan prefetching. Now it controls how many pages ahead a sequential scan, a &lt;code&gt;VACUUM&lt;/code&gt;, or a checkpoint will request before it actually needs them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;effective_io_concurrency&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;16    # SSD: 16-64&lt;/span&gt;
&lt;span class="py"&gt;effective_io_concurrency&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;200   # NVMe: 64-256&lt;/span&gt;
&lt;span class="py"&gt;effective_io_concurrency&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2      # HDD: seeks dominate, async barely helps&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this matters in production
&lt;/h2&gt;

&lt;p&gt;The scenarios where this pays off are the ones that were always I/O-bound: a nightly ETL job doing a full sequential scan of a fact table, &lt;code&gt;VACUUM&lt;/code&gt; chewing through a heavily-updated table whose pages aren't in &lt;code&gt;shared_buffers&lt;/code&gt;, a checkpoint flushing a large batch of dirty pages, a replication standby trying to flush WAL fast enough to keep lag down.&lt;/p&gt;

&lt;p&gt;On paper, a sequential scan of a 10GB table doing 1.3 million page reads at roughly 50 microseconds each costs about 65 seconds fully synchronous. With requests batched 32-deep, that drops toward single-digit seconds in theory. Real workloads don't hit the theoretical ceiling (there's coordination overhead, and the OS page cache intervenes), but a 2-3x throughput improvement on genuinely I/O-bound scans is a realistic, repeatable result, not a marketing number.&lt;/p&gt;

&lt;p&gt;The part that matters just as much: if your working set already fits in &lt;code&gt;shared_buffers&lt;/code&gt; or the OS page cache, async I/O buys you almost nothing. A cache hit has no I/O latency to overlap in the first place. This isn't a "just turn it on and everything gets faster" feature. It's a fix for exactly one bottleneck: waiting on disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying it on your own workload
&lt;/h2&gt;

&lt;p&gt;PostgreSQL 18 also ships &lt;code&gt;pg_stat_io&lt;/code&gt;, the first built-in view that breaks down I/O by process type and operation. This is how you check whether async I/O is actually doing anything for you, instead of taking it on faith:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_stat_reset_shared&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'io'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- run your actual workload here&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;backend_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;reads&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;read_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;reads&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;read_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;reads_per_second&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_io&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;reads&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;reads&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;Run that once with &lt;code&gt;effective_io_concurrency = 1&lt;/code&gt; and once with it set to something like &lt;code&gt;64&lt;/code&gt;, on the same cold query, and compare &lt;code&gt;read_time&lt;/code&gt;. If the number barely moves, your data was already cached and async I/O was never going to help. If it drops meaningfully, that's your workload confirming it was genuinely I/O-bound and the new prefetching is doing real work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't change
&lt;/h2&gt;

&lt;p&gt;It's worth being precise about the boundaries. Async I/O doesn't touch MVCC, doesn't change the durability guarantees around WAL flushing, doesn't change lock acquisition, and doesn't reduce the per-connection process overhead that still makes connection pooling mandatory at scale. It's a storage-layer optimization, full stop. Everything above the buffer manager works exactly as it did before.&lt;/p&gt;

&lt;p&gt;PostgreSQL isn't getting a new execution model here. It's getting permission to stop waiting in line.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
      <category>backend</category>
    </item>
    <item>
      <title>Vector Search in Postgres: What pgvector's Indexes Actually Cost You</title>
      <dc:creator>Jatin Jain Saraf</dc:creator>
      <pubDate>Wed, 05 Aug 2026 18:23:39 +0000</pubDate>
      <link>https://dev.to/jatinjainsaraf/vector-search-in-postgres-what-pgvectors-indexes-actually-cost-you-15kj</link>
      <guid>https://dev.to/jatinjainsaraf/vector-search-in-postgres-what-pgvectors-indexes-actually-cost-you-15kj</guid>
      <description>&lt;h2&gt;
  
  
  First, What Are We Even Searching?
&lt;/h2&gt;

&lt;p&gt;Let's ground this in one real example and stick with it the whole way through: you're building a "find similar support tickets" feature. A customer submits a new ticket, and you want to show them five old tickets that are about the same problem, even if they used totally different words to describe it.&lt;/p&gt;

&lt;p&gt;To do that, you can't search for matching keywords, because "my payment failed" and "checkout won't go through" mean the same thing but share zero words. So instead, every ticket gets converted into a list of a few hundred (or a few thousand) numbers, called an &lt;strong&gt;embedding&lt;/strong&gt;, using an AI model. Two tickets about similar problems end up with number-lists that are mathematically close to each other. Two tickets about unrelated problems end up far apart.&lt;/p&gt;

&lt;p&gt;That list of numbers is what Postgres calls a &lt;strong&gt;vector&lt;/strong&gt;. "Vector search" just means: given one ticket's list of numbers, find the other tickets whose lists of numbers are closest to it. &lt;code&gt;pgvector&lt;/code&gt; is the Postgres extension that lets you store these number-lists in a normal table column and search through them with plain SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Part Every Tutorial Skips
&lt;/h2&gt;

&lt;p&gt;Every "add AI to your app" tutorial has the same steps: install &lt;code&gt;pgvector&lt;/code&gt;, add a &lt;code&gt;vector&lt;/code&gt; column, run one search query, done. And for a demo with a thousand tickets, that's genuinely all it takes. It just works.&lt;/p&gt;

&lt;p&gt;Here's the part tutorials skip: once your support-ticket table grows to a few hundred thousand rows, that same search query, which used to take 8 milliseconds, now takes 900 milliseconds. Someone on your team says "just add an index," like that fixes it the way adding an index fixes a normal slow query. It doesn't, not in the same simple way. Picking the right kind of index means giving up a small amount of accuracy in exchange for speed, and &lt;em&gt;how much&lt;/em&gt; accuracy you give up is the whole subject of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Normal Index Doesn't Work Here
&lt;/h2&gt;

&lt;p&gt;A normal Postgres index (a B-tree) is built for questions like "give me every ticket where &lt;code&gt;status = 'open'&lt;/code&gt;." That's an exact match: a row either satisfies it or it doesn't.&lt;/p&gt;

&lt;p&gt;A vector search question is different in kind: "of these 2 million tickets, which five have number-lists closest to this new ticket's number-list?" There's no exact match to look up. Every single row has to have its distance calculated and compared.&lt;/p&gt;

&lt;p&gt;Postgres can do this the honest way: calculate the distance from your new ticket to every other ticket in the table, one by one, then sort and keep the closest five. This is called an &lt;strong&gt;exact search&lt;/strong&gt;, and it's exactly what your thousand-row demo was quietly doing. At a thousand rows, checking every single one is instant, so you never noticed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; the time this takes grows in a straight line with the number of rows. Double your tickets, double the search time. Forever. There's no clever Postgres trick, no &lt;code&gt;ANALYZE&lt;/code&gt;, no &lt;code&gt;EXPLAIN&lt;/code&gt; setting that fixes this, because the problem isn't that Postgres is being inefficient. It's that checking every row really is the only way to get a guaranteed-correct answer. The only way to get faster is to stop insisting on a guaranteed-correct answer, and accept "almost certainly correct" instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Ways to Cheat (on Purpose): IVFFlat and HNSW
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pgvector&lt;/code&gt; gives you two index types that both make this trade deliberately: they return an answer that's &lt;em&gt;usually&lt;/em&gt; the true closest five tickets, but occasionally misses one, in exchange for being dramatically faster. This is called &lt;strong&gt;approximate nearest neighbor search&lt;/strong&gt;, or ANN for short. Think of it like asking a knowledgeable local for the nearest coffee shop instead of checking every coffee shop in the city yourself: almost always right, occasionally not the actual single closest one, but you get an answer in two seconds instead of an hour.&lt;/p&gt;

&lt;p&gt;The two options, IVFFlat and HNSW, cheat in different ways, and picking between them is the actual skill here.&lt;/p&gt;

&lt;h3&gt;
  
  
  IVFFlat: Sort Tickets Into Labeled Bins First
&lt;/h3&gt;

&lt;p&gt;Picture a post office that pre-sorts mail into bins by zip code before a carrier ever looks at it. IVFFlat does the same thing to your tickets, before any search happens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When you build the index, Postgres groups all your existing tickets into a fixed number of bins (you choose how many, say 100), based on which tickets' number-lists are similar to each other. Each bin gets a "center point" that represents everything in it, the same way a zip code represents a neighborhood.&lt;/li&gt;
&lt;li&gt;When a new ticket comes in and you search, Postgres doesn't check all 100 bins. It only checks a handful of the bins whose center point is closest to your new ticket (you choose how many to check, say 10).&lt;/li&gt;
&lt;li&gt;Inside just those 10 bins, it does the slow, exact, check-every-row search, which is now fast because there are far fewer rows to check.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- build the index: sort existing tickets into 100 bins&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;support_tickets&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;ivfflat&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lists&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- when searching: only check the 10 closest bins&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;ivfflat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;probes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;support_tickets&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'[0.1, 0.2, ...]'&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The catch nobody mentions in the quickstart:&lt;/strong&gt; those bins are decided once, on the day you build the index, based on the tickets that existed &lt;em&gt;that day&lt;/em&gt;. Six months later, your product has a whole new feature, and a wave of brand-new kinds of tickets start coming in that don't look like anything in your original bins. Postgres still has to put them somewhere, so it shoves each new ticket into whichever old bin is the "least bad fit," even if that bin isn't really a good match. Slowly, quietly, your search results get worse, and there's no error message telling you this is happening. The fix is to periodically rebuild the index (&lt;code&gt;REINDEX&lt;/code&gt;) so the bins get redrawn using current data, which on a big table is a real maintenance job, not a background setting you flip once.&lt;/p&gt;

&lt;h3&gt;
  
  
  HNSW: Build a Map With Highways and Side Streets
&lt;/h3&gt;

&lt;p&gt;HNSW works completely differently: instead of bins, it builds a connected map between all your tickets, like a road network, with some very long "highways" connecting far-apart regions and lots of short local roads connecting nearby tickets.&lt;/p&gt;

&lt;p&gt;Think about how you'd actually travel from a small town to another small town on the far side of the country. You wouldn't drive the whole way on back roads. You'd take a short local road to a highway, drive the highway most of the way, then take local roads again at the other end. HNSW searches the exact same way: start on the "highway" layer where a few big jumps get you into the right general area fast, then drop down onto smaller, denser layers to fine-tune and land on the actual closest tickets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- build the index: construct the layered map, m = how many roads each point connects to&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;support_tickets&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="n"&gt;vector_cosine_ops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ef_construction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- when searching: how wide an area to explore before settling on an answer&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;hnsw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ef_search&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;m&lt;/code&gt; is roughly "how many roads does each ticket connect to." More roads means better odds of finding the true closest tickets, but the map itself takes up more memory. &lt;code&gt;ef_search&lt;/code&gt; is roughly "how much of the map do you explore before giving up and answering." Explore more, and you get a more accurate answer, but it takes longer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this matters in production:&lt;/strong&gt; because HNSW never sorts tickets into fixed bins, it doesn't go stale the way IVFFlat does. New kinds of tickets just get woven into the existing map naturally. But that map has real costs. Every single ticket stores a list of which other tickets it's "connected to" by road, and that list has to live somewhere, so &lt;strong&gt;memory use grows with how many roads each point has (&lt;code&gt;m&lt;/code&gt;) and how many tickets you have&lt;/strong&gt;, not just with how big your rows are. A table that used to fit comfortably in memory as plain rows can suddenly need a lot more RAM once this road-map is layered on top. Also, building this map isn't a quick one-time calculation like a normal index: Postgres has to insert each ticket into the map one at a time, figuring out its connections as it goes. On a table with tens of millions of tickets, that build can take hours, and depending on your Postgres version it can hold a lock that blocks other people from writing to that table the whole time. If tickets are constantly being added, that's a real scheduling problem you need to plan around, not something to discover mid-build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting Both Side by Side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Exact search (no index)&lt;/th&gt;
&lt;th&gt;IVFFlat (labeled bins)&lt;/th&gt;
&lt;th&gt;HNSW (road map)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Always finds the true closest matches?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No, usually close&lt;/td&gt;
&lt;td&gt;No, usually close&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gets slower as the table grows?&lt;/td&gt;
&lt;td&gt;Yes, in a straight line&lt;/td&gt;
&lt;td&gt;Yes, but much more slowly&lt;/td&gt;
&lt;td&gt;Yes, but even more slowly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Handles brand-new kinds of data well?&lt;/td&gt;
&lt;td&gt;Doesn't matter, always exact&lt;/td&gt;
&lt;td&gt;Poorly, needs periodic rebuilding&lt;/td&gt;
&lt;td&gt;Well, no fixed bins to outgrow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost to build the index&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Cheap, one quick grouping pass&lt;/td&gt;
&lt;td&gt;Expensive, inserts one ticket at a time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra memory needed&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;A little, just the bin centers&lt;/td&gt;
&lt;td&gt;More, scales with how many "roads" each ticket has&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good for tables with constant new writes?&lt;/td&gt;
&lt;td&gt;Fine, just slow&lt;/td&gt;
&lt;td&gt;Accuracy quietly drifts over time&lt;/td&gt;
&lt;td&gt;Handles it well, but writes slow down as the map grows&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Neither one is simply "the better index." If your ticket table is built once and doesn't change character much over time, like a fixed product catalog, IVFFlat's cheap build and simple bins are the sensible default. If your table keeps growing and keeps having new, different kinds of data written to it all the time, like a live support-ticket stream, HNSW's steadier accuracy is usually worth its slower, more expensive build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Question That Actually Matters
&lt;/h2&gt;

&lt;p&gt;Before shipping either index, the important question isn't "which one is faster," because both are fast enough for almost any real app. The real question is: &lt;strong&gt;how often is it okay for this search to miss the actual best match, and have you ever checked?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That "miss rate" is called &lt;strong&gt;recall&lt;/strong&gt;: the percentage of the true best matches your approximate index actually returns. It doesn't show up anywhere. It won't throw an error, and &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; won't flag it. The only way anyone finds out recall has gotten bad is a vague complaint months later, something like "the suggested tickets don't feel as relevant as they used to," long after the table has grown large enough for the approximation to start visibly slipping.&lt;/p&gt;

&lt;p&gt;The fix is simple to say and easy to skip: while your table is still small enough to run a true exact search for comparison, run one, and compare its results against what your approximate index returns. That tells you your real recall number. Do this early, because once the table is big enough that an exact search is too slow to run anymore, you've lost your only way of checking whether the fast index is still giving you good answers.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>ai</category>
      <category>backend</category>
    </item>
    <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>
  </channel>
</rss>
