<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Ackshaey Singh</title>
    <link>https://ackshaey.com</link>
    <description>Ackshaey Singh leads growth engineering at Opendoor. Essays on growth engineering, ad-tech and martech, experimentation, SEO/AEO, and the AI systems that accelerate teams, with production code and real numbers.</description>
    <language>en</language>
    <atom:link href="https://ackshaey.com/rss.xml" rel="self" type="application/rss+xml" />
    <lastBuildDate>Sat, 06 Jun 2026 00:00:00 GMT</lastBuildDate>
    
    <item>
      <title>You Should Care About Database Connections</title>
      <link>https://ackshaey.com/blog/you-should-care-about-database-connections</link>
      <guid isPermaLink="true">https://ackshaey.com/blog/you-should-care-about-database-connections</guid>
      <pubDate>Sat, 06 Jun 2026 00:00:00 GMT</pubDate>
      <description>A from-first-principles primer on database connections, why they run out under load, and how connection pooling with PgBouncer or a managed Postgres like Neon fixes it. The examples are Rails and Postgres, the ideas carry to any stack.</description>
      <category>Infrastructure</category>
      <content:encoded><![CDATA[<p>I recently set up a dedicated PgBouncer pod for <a href="https://designerdiscount.club">Designer Discount Club</a>, and it is a good excuse to explain how database connections actually work. Most of us reach for an ORM like ActiveRecord and never look at what sits underneath it. The reason I needed PgBouncer is specific: DDC&#39;s background jobs are bursty, the Sidekiq workers autoscale hard under load, and without a pooler in front of Postgres they would open more connections than the database can hold. The examples below are Rails and Postgres, but the model applies to almost any framework and any database.</p>
<h2>What is a database connection?</h2>
<p>When your app opens a connection to Postgres, it does not get a lightweight handle into a shared server. Postgres uses a process-per-connection model: a supervisor called the postmaster forks a brand new backend process dedicated to that one connection, and that process lives until the connection closes. Opening it is not free either, because the client and server complete a TCP handshake, then authentication, then session setup before a single query runs.</p>
<p>&lt;Mermaid
  caption=&quot;One client connection maps to one dedicated Postgres backend process.&quot;
  chart={<code>flowchart LR   subgraph APP[&quot;Your app&quot;]     C1[&quot;Client conn 1&quot;]     C2[&quot;Client conn 2&quot;]     C3[&quot;Client conn 3&quot;]   end   C1 --&gt;|&quot;TCP + auth&quot;| PM   C2 --&gt;|&quot;TCP + auth&quot;| PM   C3 --&gt;|&quot;TCP + auth&quot;| PM   PM{{&quot;postmaster&lt;br/&gt;(supervisor)&quot;}}   PM --&gt;|&quot;fork()&quot;| B1[&quot;Backend 1&lt;br/&gt;~5-10 MB&quot;]   PM --&gt;|&quot;fork()&quot;| B2[&quot;Backend 2&lt;br/&gt;~5-10 MB&quot;]   PM --&gt;|&quot;fork()&quot;| B3[&quot;Backend 3&lt;br/&gt;~5-10 MB&quot;]   B1 --&gt; SHM[(&quot;Shared buffers&lt;br/&gt;WAL / tables&quot;)]   B2 --&gt; SHM   B3 --&gt; SHM</code>}
/&gt;</p>
<p>Each backend holds its own memory for query plans, caches, and sort buffers, on the order of 5 to 10 MB. Multiply that by a few thousand connections and the server spends its memory and its CPU scheduling processes instead of answering queries. This is why Postgres ships with <code>max_connections</code> set near 100 rather than 100,000. The limit is deliberately low.</p>
<p>A connection also carries state across queries, and prepared statements are the clearest example. By default, Rails uses server-side prepared statements. The first time it runs a given query shape, it sends a PREPARE so Postgres parses and plans the query once, then sends EXECUTE to reuse that saved plan on every later call.</p>
<p>&lt;Mermaid
  caption=&quot;A prepared statement is parsed and planned once, then reused, on one specific backend.&quot;
  chart={<code>sequenceDiagram   participant A as Rails (ActiveRecord)   participant B as One Postgres backend   A-&gt;&gt;B: PREPARE s1 (&quot;... WHERE id = $1&quot;)   B--&gt;&gt;A: parsed + planned, saved as s1   A-&gt;&gt;B: EXECUTE s1 (id = 42)   B--&gt;&gt;A: rows   A-&gt;&gt;B: EXECUTE s1 (id = 99)   B--&gt;&gt;A: rows   Note over B: the plan for s1 lives only on THIS backend</code>}
/&gt;</p>
<p>That makes repeated queries faster, and it depends on your next query reaching the same backend process. A connection pooler can route that next query to a different backend, which is what breaks prepared statements later in this post.</p>
<h2>What is connection pooling?</h2>
<p>Opening a fresh connection per query would pay the fork, handshake, and authentication cost every single time, which is far too expensive for a busy app. A connection pool fixes that by keeping a fixed set of connections open and lending them out. A thread checks out a connection, runs its queries, and returns it to the pool for the next thread to use.</p>
<p>&lt;Mermaid
  caption=&quot;A pool keeps a few connections open and shares them across many threads in one process.&quot;
  chart={<code>flowchart TB   subgraph PROC[&quot;One Ruby process&quot;]     direction LR     T1[&quot;Thread 1&quot;] ~~~ T2[&quot;Thread 2&quot;] ~~~ T3[&quot;Thread 3&lt;br/&gt;(waiting)&quot;]   end   PROC --&gt; POOL   subgraph POOL[&quot;ActiveRecord pool, size 5&quot;]     direction LR     S1[&quot;busy&quot;] ~~~ S2[&quot;busy&quot;] ~~~ S3[&quot;idle&quot;] ~~~ S4[&quot;idle&quot;] ~~~ S5[&quot;idle&quot;]   end   POOL ==&gt;|&quot;reuses 5 real connections&quot;| DB[(&quot;Postgres&quot;)]</code>}
/&gt;</p>
<p>In Rails this is the ActiveRecord pool, sized by <code>pool:</code> in <code>config/database.yml</code> and defaulting to five connections. Every Ruby process keeps its own separate pool. The pool only knows about the threads inside its own process, and it has no idea how many other processes are connected to the same database. That per-process scope is the detail behind most connection problems at scale.</p>
<h2>How do autoscaled workers starve the pool?</h2>
<p>DDC runs a Rails web tier and a Sidekiq background-job tier on GKE, all pointed at one Cloud SQL Postgres. I autoscale the Sidekiq workers with Kubernetes Event-Driven Autoscaling (KEDA), which adds and removes pods based on the Sidekiq queue depth rather than CPU or memory. Queue depth is the right signal here, because one catalog-sync job can enqueue tens of thousands of child jobs in seconds while CPU stays flat as the backlog builds.</p>
<p>Each worker pod runs ten Sidekiq threads and a connection pool of ten to match them. Three pods hold thirty connections, which is fine. A backlog that pushes KEDA to sixty pods holds six hundred connections, and Postgres is still capped near one hundred.</p>
<p>&lt;Mermaid
  caption=&quot;Each autoscaled pod brings its own pool. Sixty pods want 600 connections against a 100-connection database.&quot;
  chart={<code>flowchart TB   subgraph FLEET[&quot;Sidekiq pods (KEDA-scaled)&quot;]     direction LR     P1[&quot;Pod 1&lt;br/&gt;pool 10&quot;]     P2[&quot;Pod 2&lt;br/&gt;pool 10&quot;]     P3[&quot;Pod 3&lt;br/&gt;pool 10&quot;]     PMORE[&quot;...&quot;]     P60[&quot;Pod 60&lt;br/&gt;pool 10&quot;]   end   P1 --&gt; SUM   P2 --&gt; SUM   P3 --&gt; SUM   P60 --&gt; SUM   SUM{{&quot;600 connections requested&quot;}}   SUM --&gt;|&quot;first 100 accepted&quot;| OK[(&quot;Postgres&lt;br/&gt;max_connections = 100&quot;)]   SUM --&gt;|&quot;the rest rejected&quot;| ERR[&quot;FATAL: sorry,&lt;br/&gt;too many clients already&quot;]</code>}
/&gt;</p>
<p>The Rails config shows why nothing stops this. The pool size is set per process, so it scales linearly with the number of pods.</p>
<pre><code class="language-yaml">production:
  adapter: postgresql
  database: ddc_production
  # Per process. Sixty pods means sixty separate pools of this size.
  pool: &lt;%= ENV.fetch(&quot;RAILS_MAX_THREADS&quot;, 10) %&gt;
  host: &lt;%= ENV[&quot;DATABASE_HOST&quot;] %&gt;
</code></pre>
<p>There is no setting in here for the total across the fleet, because Rails has no concept of the fleet. The database enforces the only global limit that exists, <code>max_connections</code>, and it rejects every connection past that limit with <code>FATAL: sorry, too many clients already</code>. The workers would hit that wall long before they drained the queue.</p>
<h2>How does PgBouncer fix it?</h2>
<p>PgBouncer is a connection pooler that runs as its own process between your fleet and Postgres. Your apps and workers connect to PgBouncer, which is cheap to connect to, and PgBouncer keeps a small set of real Postgres connections that it lends out on demand. It multiplexes thousands of client connections onto a few dozen real ones.</p>
<p>&lt;Mermaid
  caption=&quot;PgBouncer multiplexes many cheap client connections onto a few real Postgres backends.&quot;
  chart={<code>flowchart TB   subgraph CLIENTS[&quot;Apps + workers&quot;]     direction LR     X1[&quot;client&quot;]     X2[&quot;client&quot;]     X3[&quot;client&quot;]     X4[&quot;client&quot;]   end   CLIENTS ==&gt;|&quot;up to 5000 clients&quot;| PB   PB{{&quot;PgBouncer&lt;br/&gt;pool_mode = transaction&quot;}}   PB ==&gt;|&quot;~25 real backends&quot;| DB[(&quot;Postgres&lt;br/&gt;max_connections = 100&quot;)]</code>}
/&gt;</p>
<p>How aggressively it multiplexes is set by <code>pool_mode</code>.</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Backend returns to the pool</th>
<th>Safe for</th>
<th>Catch</th>
</tr>
</thead>
<tbody><tr>
<td><code>session</code></td>
<td>when the client disconnects</td>
<td>everything</td>
<td>barely better than no pooler under fan-out</td>
</tr>
<tr>
<td><code>transaction</code></td>
<td>at the end of each transaction</td>
<td>most apps</td>
<td>breaks cross-transaction session state</td>
</tr>
<tr>
<td><code>statement</code></td>
<td>after each statement</td>
<td>autocommit-only workloads</td>
<td>forbids multi-statement transactions</td>
</tr>
</tbody></table>
<p>Transaction mode is the one that does the real work. A client holds a real backend only from <code>BEGIN</code> to <code>COMMIT</code>, and then PgBouncer returns it to the pool, so idle clients between transactions hold nothing. That is how twenty-five real connections can serve six hundred clients at once.</p>
<pre><code class="language-ini">[databases]
appdb = host=10.0.0.5 port=5432 dbname=appdb

[pgbouncer]
listen_port = 6432
auth_type   = scram-sha-256
auth_file   = /etc/pgbouncer/userlist.txt

# The line that matters. Hand the backend back at COMMIT.
pool_mode = transaction

# Cheap: client connections are lightweight, so set this high.
max_client_conn = 5000

# Expensive: real Postgres backends. Keep this under max_connections.
default_pool_size = 25
</code></pre>
<p>Point the apps at PgBouncer on port <code>6432</code> and shrink the per-process pools, since the pooler owns the real limit now. Transaction mode gives up one guarantee in return: that a connection stays yours across statements. That breaks the two Rails defaults from earlier in this post. Server-side prepared statements can execute on a backend that never prepared them, and Rails migrations take a session-scoped advisory lock that a later transaction may never land on. Turn both off on the pooled connection, and run migrations through a separate direct connection.</p>
<pre><code class="language-yaml">production:
  primary:
    url: &lt;%= ENV[&quot;DATABASE_URL&quot;] %&gt;            # PgBouncer, port 6432
    prepared_statements: false
    advisory_locks: false
  primary_migration:
    url: &lt;%= ENV[&quot;DIRECT_DATABASE_URL&quot;] %&gt;     # direct Postgres, port 5432
    migrations_paths: db/migrate
</code></pre>
<h2>How do Neon and friends do this for you?</h2>
<p>Managed Postgres services bake this layer in, so you connect to a pooled endpoint and never run the pooler yourself. Supabase exposes a transaction-mode pooler on a separate port, <code>6543</code>, next to the direct one. Neon gives you a pooled connection string for serverless workloads when you add <code>-pooler</code> to the host name. RDS Proxy is the managed version for Amazon RDS and Aurora.</p>
<p>&lt;Mermaid
  caption=&quot;Managed Postgres runs the same transaction pooler for you, behind a pooled endpoint.&quot;
  chart={<code>flowchart LR   APP[&quot;App + workers&quot;] ==&gt;|&quot;pooled endpoint&lt;br/&gt;(-pooler / :6543)&quot;| MP   MIG[&quot;Migrations&quot;] --&gt;|&quot;direct endpoint (:5432)&quot;| DB   MP{{&quot;Managed pooler&lt;br/&gt;(PgBouncer-equivalent)&lt;br/&gt;transaction mode&quot;}}   MP ==&gt; DB[(&quot;Postgres&quot;)]</code>}
/&gt;</p>
<p>In every one of these cases you are talking to PgBouncer or a close equivalent in transaction mode, with the process run and scaled for you. I run our own PgBouncer pod for DDC because Google Cloud SQL only includes managed pooling on its Enterprise Plus edition, and paying to upgrade editions makes no sense when a light pod does the same job.</p>
<h2>Which layer should own the connection limit?</h2>
<p>Two layers pool connections, and they cover different scopes. Your ORM pool reuses connections inside one process, and a transaction pooler caps the total across every process hitting the database. Rails gives you the first and nothing for the second. Decide whether PgBouncer or a managed endpoint owns that global limit, set it explicitly, and size the per-process pools to fit underneath it.</p>
]]></content:encoded>
    </item>
  </channel>
</rss>