<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Connection Pooling | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/connection-pooling/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/connection-pooling/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Thu, 08 Oct 2026 06:17:49 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.3</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Connection Pooling | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/connection-pooling/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>Plenty of CPU, Zero Connections: Debugging Connection Pool Exhaustion on AWS</title>
		<link>https://john-nessime.com/blog/cloud-computing/connection-pool-exhaustion-aws/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 08 Oct 2026 06:17:49 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Databases]]></category>
		<category><![CDATA[Site Reliability Engineering]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Amazon Aurora]]></category>
		<category><![CDATA[Amazon ECS]]></category>
		<category><![CDATA[Amazon RDS]]></category>
		<category><![CDATA[CloudWatch]]></category>
		<category><![CDATA[Connection Leaks]]></category>
		<category><![CDATA[Connection Pinning]]></category>
		<category><![CDATA[Connection Pooling]]></category>
		<category><![CDATA[HikariCP]]></category>
		<category><![CDATA[Idle in Transaction]]></category>
		<category><![CDATA[Incident Response]]></category>
		<category><![CDATA[max_connections]]></category>
		<category><![CDATA[MySQL]]></category>
		<category><![CDATA[Performance Tuning]]></category>
		<category><![CDATA[PgBouncer]]></category>
		<category><![CDATA[Pool Exhaustion]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[RDS Proxy]]></category>
		<category><![CDATA[SRE]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=1146</guid>

					<description><![CDATA[<p>Pool timeouts while RDS sits at low CPU? The database isn't the bottleneck. Here's how to tell whether your app pool, max_connections or RDS Proxy ran out, the SQL that proves it, and the fixes that survive the next deploy.</p>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/connection-pool-exhaustion-aws/">Plenty of CPU, Zero Connections: Debugging Connection Pool Exhaustion on AWS</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The alert says p99 latency is climbing. The application logs are full of <code>Connection is not available, request timed out after 30000ms</code>. Someone opens the RDS console, looks at CPU sitting low, freeable memory looking healthy and <code>DatabaseConnections</code> nowhere near the limit, and says the obvious thing: &#8220;The database is fine.&#8221;</p>



<p class="wp-block-paragraph">They&#8217;re right. The database is fine. That&#8217;s exactly why this incident drags on for an hour instead of ten minutes.</p>



<p class="wp-block-paragraph">Connection pool exhaustion on AWS is rarely a capacity problem. It&#8217;s a queueing problem, and the queue forms in one of three places: inside your application&#8217;s pool, at the database&#8217;s connection limit, or inside a proxy sitting between the two. Each layer has its own limit, its own error message and its own fix. Resizing the instance fixes none of them reliably, and sometimes makes one worse.</p>



<p class="wp-block-paragraph">Below: how to tell which layer ran out, the SQL and metrics that prove it, and fixes that survive the next deploy. Examples use RDS and Aurora for PostgreSQL and MySQL, plus HikariCP, SQLAlchemy and node-postgres where their defaults matter.</p>



<h2 class="wp-block-heading">First question: which layer ran out?</h2>



<p class="wp-block-paragraph">Before touching anything, figure out where requests are waiting. The error text usually tells you, if you read it literally instead of pattern-matching on &#8220;connection&#8221;.</p>



<ul class="wp-block-list">
<li><strong>The application pool is empty.</strong> The error comes from your pool library, not the database. HikariCP says the connection is not available and the request timed out. SQLAlchemy raises a <code>QueuePool limit ... reached</code> timeout. The database never saw these requests at all.</li>



<li><strong>The database refused new connections.</strong> The error comes from the server. PostgreSQL returns a <code>FATAL</code> about too many clients or about the remaining connection slots being reserved (the exact wording varies by major version). MySQL returns error 1040, <code>Too many connections</code>.</li>



<li><strong>The proxy is holding the queue.</strong> With RDS Proxy in the path, clients connect fine but wait for a database connection to be borrowed, then time out after the proxy&#8217;s borrow timeout.</li>
</ul>



<p class="wp-block-paragraph">Then compare what the database is actually holding against its limit. On PostgreSQL:</p>



<pre class="wp-block-code"><code>SHOW max_connections;

SELECT count(*) AS client_connections
FROM pg_stat_activity
WHERE backend_type = 'client backend';</code></pre>



<p class="wp-block-paragraph">On MySQL, <code>Max_used_connections</code> is the high-water mark since the last restart, which is useful when the spike has already passed:</p>



<pre class="wp-block-code"><code>SHOW VARIABLES LIKE 'max_connections';
SHOW GLOBAL STATUS LIKE 'Threads_connected';
SHOW GLOBAL STATUS LIKE 'Max_used_connections';</code></pre>



<p class="wp-block-paragraph">If the app is throwing pool timeouts while the server is well under its limit, you&#8217;re in the first failure family. If the server is at the limit, it&#8217;s the second. That single comparison saves you from the most common wrong move, which is raising <code>max_connections</code> when the database was never the bottleneck.</p>



<h2 class="wp-block-heading">Pool exhaustion with an idle database: the one that hides</h2>



<p class="wp-block-paragraph">This is the failure that fools people, because every database graph looks healthy while the application is effectively down.</p>



<p class="wp-block-paragraph">A pool hands out a fixed number of connections per process. A request borrows one, and nobody else can use it until it&#8217;s returned. The pool doesn&#8217;t care whether the request is running SQL during that time. If your code opens a transaction, runs a query, then calls a payment API, uploads to S3 or sends an email before committing, the connection sits borrowed and idle for the whole round trip.</p>



<p class="wp-block-paragraph">Now let that third-party API slow down. Each request holds its connection longer, the pool drains, new requests queue for a connection, and they time out. The database sees a handful of sessions doing nothing. CPU drops, because less SQL is running than usual. The dashboard gets greener as the outage gets worse.</p>



<p class="wp-block-paragraph">So <strong>a slow dependency that has nothing to do with the database can exhaust your database pool</strong>, and database metrics will never show it.</p>



<h3 class="wp-block-heading">Prove it with pg_stat_activity</h3>



<p class="wp-block-paragraph">Group sessions by state and by who owns them. The <code>state</code> column is the evidence; <code>idle in transaction</code> means a client opened a transaction and then went quiet without committing.</p>



<pre class="wp-block-code"><code>SELECT state,
       application_name,
       count(*) AS sessions,
       max(now() - xact_start) AS oldest_transaction
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY state, application_name
ORDER BY sessions DESC;</code></pre>



<p class="wp-block-paragraph">Then list the worst offenders. The <code>query</code> column shows the last statement the session ran, not what it&#8217;s doing now, which is exactly what you want here: it tells you where in the code the transaction was left open.</p>



<pre class="wp-block-code"><code>SELECT pid,
       application_name,
       client_addr,
       now() - xact_start AS transaction_age,
       left(query, 80) AS last_statement
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start;</code></pre>



<p class="wp-block-paragraph">On MySQL, open transactions live in <code>information_schema.INNODB_TRX</code>. The thread ID joins back to the process list so you can see the host and user:</p>



<pre class="wp-block-code"><code>SELECT trx_mysql_thread_id, trx_started, trx_state
FROM information_schema.INNODB_TRX
ORDER BY trx_started;</code></pre>



<p class="wp-block-paragraph">One thing makes all of this much faster: set <code>application_name</code> per service in the connection string. Without it, every session looks the same and you&#8217;re guessing from IP addresses that change with every task restart.</p>



<h3 class="wp-block-heading">Leak or slow holder?</h3>



<p class="wp-block-paragraph">Two different bugs produce the same symptom. A <strong>leak</strong> is a code path that borrows a connection and never returns it, usually an exception path that skips the close. A <strong>slow holder</strong> returns the connection eventually but keeps it through non-database work.</p>



<p class="wp-block-paragraph">The tell is recovery. Leaks never recover without a restart; the pool ratchets down one connection at a time until it&#8217;s empty. Slow holders recover when the slow dependency recovers. If restarting the service &#8220;fixes&#8221; the incident every few days, suspect a leak.</p>



<p class="wp-block-paragraph">HikariCP has <code>leakDetectionThreshold</code>, which is off by default. Set it to a value longer than your slowest legitimate request, and it logs the stack trace of wherever a connection was borrowed and held past that threshold. That stack trace is usually the whole diagnosis. Other pools have their own hooks; if yours doesn&#8217;t, log borrow duration yourself around the checkout.</p>



<h3 class="wp-block-heading">Fixes that actually hold</h3>



<ol class="wp-block-list">
<li><strong>Stop holding connections across network calls.</strong> Do the external call first, then open the transaction, write, and commit. If you need the call&#8217;s result inside the transaction, restructure so the transaction is short. This is the real fix; everything else is a guardrail.</li>



<li><strong>Put a server-side ceiling on idle transactions.</strong> PostgreSQL&#8217;s <code>idle_in_transaction_session_timeout</code> terminates a session that sits idle inside a transaction past the limit. The app will see an error, and that&#8217;s the point: a loud failure beats a silent pool drain. You can set it in the parameter group or scope it to the application role.</li>



<li><strong>Fail fast on checkout.</strong> A web request that waits 30 seconds for a connection is worse than one that fails in two. Match the pool&#8217;s acquisition timeout to what the caller will tolerate.</li>
</ol>



<pre class="wp-block-code"><code>ALTER ROLE app_user SET idle_in_transaction_session_timeout = '60s';</code></pre>



<p class="wp-block-paragraph">The role-level setting applies to new sessions for that role, so existing pooled connections keep the old value until they&#8217;re recycled. Pick a limit comfortably above your longest legitimate pause, or you&#8217;ll kill batch jobs that were behaving.</p>



<p class="wp-block-paragraph">MySQL has no direct equivalent. <code>wait_timeout</code> closes idle sessions, but it&#8217;s a blunt setting that also affects pooled connections sitting legitimately idle, so tune it alongside your pool&#8217;s own idle and max-lifetime settings rather than instead of fixing the code.</p>



<p class="wp-block-paragraph">Watch the node-postgres default specifically. Its pool <code>connectionTimeoutMillis</code> defaults to 0, which means no timeout: when the pool is empty, requests wait forever. You don&#8217;t get an error to alert on; you get hung requests and upstream timeouts that point everywhere except the pool.</p>



<h2 class="wp-block-heading">Server slots run out while CPU idles</h2>



<p class="wp-block-paragraph">The second family is the one people expect: the database refuses connections. The surprise is that it happens at low CPU, and that the cause is arithmetic, not load.</p>



<h3 class="wp-block-heading">Pool multiplication</h3>



<p class="wp-block-paragraph">Every pool size is per process. The number the database actually faces is the product of everything that opens a pool:</p>



<pre class="wp-block-code"><code>worst case = pool size
           x worker processes per container
           x containers at the autoscaler's maximum
           x 2 during a rolling deploy (old and new running together)
           + cron jobs, migrations, BI tools, admin sessions</code></pre>



<p class="wp-block-paragraph">Take an illustrative case: a pool of 10, four worker processes per ECS task, 30 tasks at the scaling maximum. That&#8217;s 1,200 potential connections before a deploy briefly doubles it. Nobody chose that number. It emerged from three settings owned by three different people, and it only shows up on the day traffic and a deploy coincide.</p>



<p class="wp-block-paragraph">Use the autoscaler&#8217;s maximum, not today&#8217;s task count. Today&#8217;s count is what makes the math look safe.</p>



<h3 class="wp-block-heading">The limit moves when you resize</h3>



<p class="wp-block-paragraph">On RDS and Aurora, the default <code>max_connections</code> in the parameter group is a formula based on the instance class&#8217;s memory, not a fixed number. So a rightsizing exercise that drops the instance a size to save money also quietly lowers the connection ceiling. The CPU graph said it was safe. The connection budget didn&#8217;t get a vote.</p>



<p class="wp-block-paragraph">Don&#8217;t calculate the value by hand; the memory figure RDS uses excludes what its own processes consume. Run <code>SHOW max_connections</code> on the actual instance, and re-run it after every class change.</p>



<h3 class="wp-block-heading">Why raising max_connections is the wrong reflex</h3>



<p class="wp-block-paragraph">You can override <code>max_connections</code> in a custom parameter group, and sometimes a modest bump is fine. But it treats the symptom. Each PostgreSQL connection is a separate backend process with its own memory, and sorts or hashes inside it can each use up to <code>work_mem</code>. Thousands of connections all trying to work at once contend for the same CPU cores, and throughput often drops as concurrency rises past what the hardware can run in parallel.</p>



<p class="wp-block-paragraph">Also keep in mind that the last few slots are reserved for administrative roles. The application starts failing before the counter reaches the limit, and when it does, you may struggle to get your own session in to diagnose it. Keep headroom for humans.</p>



<h3 class="wp-block-heading">Budget connections on purpose</h3>



<ol class="wp-block-list">
<li>Read the real <code>max_connections</code> from the instance and subtract a margin for admin sessions, monitoring agents and migrations.</li>



<li>List every consumer: each service, its worker count, its scaling maximum, and its batch jobs.</li>



<li>Divide the budget and set each pool&#8217;s maximum from that, including the deploy overlap.</li>



<li>Cap the autoscaler maximum at something the database can serve. An autoscaler that can add tasks faster than the database can accept connections just converts a traffic spike into a connection storm.</li>



<li>If the budget can&#8217;t cover the fleet, add a pooler in front instead of more slots.</li>
</ol>



<p class="wp-block-paragraph">Smaller pools feel scary, but a connection is only useful while SQL is running. A small pool with short transactions usually beats a large pool with long ones.</p>



<h3 class="wp-block-heading">Zombie connections</h3>



<p class="wp-block-paragraph">Some slots belong to clients that are gone. A container killed without a clean shutdown, or a Lambda execution environment frozen between invocations, can leave server-side sessions open until something times them out. With Lambda, each concurrent environment holds its own connection, so cap concurrency and put a pooler in front; the <a href="https://john-nessime.com/aws/serverless-api/">serverless API</a> work I do starts exactly there.</p>



<p class="wp-block-paragraph">For long-lived services, set the pool&#8217;s idle timeout and max lifetime below any idle timeout on the network path, so connections are retired by the pool rather than cut silently in the middle.</p>



<h2 class="wp-block-heading">When the proxy becomes the queue</h2>



<p class="wp-block-paragraph">RDS Proxy is the managed answer to pool multiplication. Many client connections share a smaller set of database connections, and the proxy hands a database connection to a client per transaction. When that works, the server-side limit stops being your problem.</p>



<p class="wp-block-paragraph">It stops working in two ways. First, <strong>pinning</strong>: when a session changes state the proxy can&#8217;t safely share, such as setting a variable on PostgreSQL, the proxy ties that client to one database connection until the session ends. Enough pinned sessions and the proxy behaves like no proxy at all. Second, the <strong>borrow queue</strong>: when every database connection the proxy is allowed to open is busy, clients wait up to <code>ConnectionBorrowTimeout</code> (120 seconds by default) and then fail. Your application sees slow requests, not refused connections, and the database graphs look calm again.</p>



<p class="wp-block-paragraph">The proxy&#8217;s own CloudWatch metrics tell you which it is. <code>DatabaseConnectionsCurrentlySessionPinned</code> shows pinning. <code>DatabaseConnectionsCurrentlyBorrowed</code> against <code>DatabaseConnections</code> shows how close the proxy is to its share of the database. And <code>MaxConnectionsPercent</code> on the target group caps that share as a percentage of <code>max_connections</code>, so a conservative setting there creates the borrow queue by itself.</p>



<p class="wp-block-paragraph">The proxy is billed on the capacity of the database behind it (per vCPU-hour for provisioned instances, per ACU-hour for Aurora Serverless), so run your current rates through something like the <a href="https://john-nessime.com/tools/aws-cost-estimator/">AWS Cost Estimator</a> before assuming it&#8217;s free relief. PgBouncer in transaction mode on a small EC2 instance is the self-managed alternative: cheaper to run, but it&#8217;s one more thing to patch, monitor and make highly available.</p>



<h2 class="wp-block-heading">Metrics that warn you before connection pool exhaustion</h2>



<p class="wp-block-paragraph">Database CPU is the wrong alarm for this problem. By the time connection pressure shows up there, if it ever does, users already noticed. The signals that lead are on the client side:</p>



<ul class="wp-block-list">
<li><strong>Threads waiting for a connection.</strong> HikariCP exposes pending threads, active and idle counts, and acquisition time through its Micrometer integration. Sustained pending threads above zero is the earliest reliable warning you get.</li>



<li><strong>Connection acquisition time.</strong> Healthy pools hand out connections almost instantly. A rising acquisition time is the queue forming, long before timeouts start.</li>



<li><strong>Server connections as a ratio of the limit.</strong> Alarm on CloudWatch <code>DatabaseConnections</code> relative to the instance&#8217;s real <code>max_connections</code>, not an absolute number that goes stale after a resize.</li>



<li><strong>Oldest open transaction.</strong> A scheduled query against <code>pg_stat_activity</code> exporting the age of the oldest <code>xact_start</code> catches slow holders and leaks early.</li>
</ul>



<p class="wp-block-paragraph">Where these land matters less than having them next to each other. Grafana with a Prometheus scrape of the application&#8217;s pool metrics works well, as do Datadog or New Relic APM, which can show pool wait time inside the request trace. Sentry is useful for grouping the pool-timeout exceptions so you see the first one, not the thousandth. The goal is one screen where app pool state and database connection counts share a time axis. If you&#8217;d rather not build that, it&#8217;s the kind of thing covered under <a href="https://john-nessime.com/aws/monitoring-alerting/">AWS monitoring and alerting</a>.</p>



<h2 class="wp-block-heading">Troubleshooting by symptom</h2>



<ul class="wp-block-list">
<li><strong>Pool timeouts, database far below its limit, many idle-in-transaction sessions:</strong> slow holder or leak. Find the last statement in <code>pg_stat_activity</code> and trace the code path that follows it.</li>



<li><strong>Pool timeouts, few database sessions at all:</strong> the pool is too small for the worker count, or connections are borrowed and held without a transaction open. Check borrow duration, not the database.</li>



<li><strong>Refused connections only during deploys:</strong> the old and new versions overlap. Budget for the overlap or slow the rollout.</li>



<li><strong>Refused connections right after an instance class change:</strong> the formula-derived limit dropped. Check <code>SHOW max_connections</code>.</li>



<li><strong>Requests hang with no error at all:</strong> a pool with no acquisition timeout, such as node-postgres at its default. Set one.</li>



<li><strong>Everything was fixed by a restart, then came back days later:</strong> a leak. Turn on leak detection and wait for the stack trace.</li>



<li><strong>RDS Proxy in the path, slow requests, calm database:</strong> check pinned and borrowed connection metrics before anything else.</li>
</ul>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Scaling the instance up because &#8220;it&#8217;s the database&#8221;, when the database was idle the whole time.</li>



<li>Raising <code>max_connections</code> to absorb pool multiplication, then losing throughput to contention.</li>



<li>Making the pool bigger to fix pool timeouts caused by long transactions. It buys minutes and moves the failure to the server limit.</li>



<li>Sizing pools from today&#8217;s task count instead of the autoscaler&#8217;s maximum.</li>



<li>Adding RDS Proxy without checking pinning, and ending up with a paid queue instead of a pool.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Keep transactions short and never hold a connection across an external call.</li>



<li>Write the connection budget down, with every consumer, and revisit it when scaling limits or instance classes change.</li>



<li>Set an acquisition timeout that matches what callers will tolerate, and alert on pending threads, not on timeouts.</li>



<li>Use <code>idle_in_transaction_session_timeout</code> on PostgreSQL as a safety net, scoped to application roles.</li>
</ul>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">What causes connection pool exhaustion when database CPU is low?</h3>



<p class="wp-block-paragraph">Usually connections held while no SQL runs: long transactions, external API calls inside a transaction, or leaked connections that were never returned. The pool is empty, but the database is doing very little.</p>



<h3 class="wp-block-heading">Should I increase max_connections on RDS?</h3>



<p class="wp-block-paragraph">Only after confirming the server limit is actually the one being hit, and only modestly. If the cause is pool multiplication, budget the pools or add a pooler. More connections means more memory per backend and more contention, not more throughput.</p>



<h3 class="wp-block-heading">How big should my connection pool be?</h3>



<p class="wp-block-paragraph">Smaller than most people set it. Start from the connection budget divided across every process at maximum scale, then load test. A pool large enough to cover every thread usually just moves the queue from the application to the database.</p>



<h3 class="wp-block-heading">Does RDS Proxy fix pool exhaustion?</h3>



<p class="wp-block-paragraph">It fixes server-side slot exhaustion from many clients, as long as sessions aren&#8217;t pinned. It does nothing for an application pool drained by long transactions, because those transactions hold the proxy&#8217;s database connections just as firmly.</p>



<h3 class="wp-block-heading">How do I find leaked connections in PostgreSQL?</h3>



<p class="wp-block-paragraph">Look for sessions in <code>pg_stat_activity</code> that stay idle or idle in transaction for far longer than any request should, grouped by <code>application_name</code>. Then turn on your pool&#8217;s leak detection to get the stack trace of where the connection was borrowed.</p>



<h3 class="wp-block-heading">Does this happen on self-hosted databases too?</h3>



<p class="wp-block-paragraph">Exactly the same way. On PostgreSQL running on a VPS from providers like Hetzner, Contabo or InterServer, the pool math and idle transactions behave identically; the difference is that you run PgBouncer yourself instead of paying for RDS Proxy.</p>



<h2 class="wp-block-heading">Conclusion</h2>



<p class="wp-block-paragraph">Connection pool exhaustion on AWS almost never means the database is too small. It means a queue formed somewhere, and the fix depends entirely on where. Read the error literally, compare the database&#8217;s connection count against its real limit, and within minutes you&#8217;ll know if you&#8217;re dealing with a drained application pool, a server limit reached by arithmetic, or a proxy holding the line.</p>



<p class="wp-block-paragraph">If you remember one thing: a connection costs you for as long as it&#8217;s borrowed, not for as long as SQL runs. Shorten the borrow and most of these incidents stop happening.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help tracking down a connection problem on AWS?</h2>



<p class="wp-block-paragraph">This is work I do for teams running RDS, Aurora or self-managed PostgreSQL and MySQL. Typical engagements look like this:</p>



<ul class="wp-block-list">
<li>Working out which layer is exhausting during an incident, from pool logs, <code>pg_stat_activity</code> and CloudWatch.</li>



<li>Building a written connection budget across services, workers, autoscaling limits and deploy overlap.</li>



<li>Finding leaks and long-held connections in application code, with leak detection and transaction timeouts set sensibly.</li>



<li>Evaluating RDS Proxy or PgBouncer for your workload, including pinning and cost.</li>



<li>Dashboards and alerts that put application pool state and database connections on the same screen.</li>



<li>Checking what an instance resize will do to your connection limit before you make it.</li>
</ul>



<p class="wp-block-paragraph">Send me the pool timeout from your logs and the output of the <code>pg_stat_activity</code> query above, and I&#8217;ll tell you which layer ran out.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://john-nessime.com/projects/hosted-monitoring/">See hosted monitoring</a></div>



<div class="wp-block-button is-style-outline is-style-outline--1"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/cloud-computing/connection-pool-exhaustion-aws/">Plenty of CPU, Zero Connections: Debugging Connection Pool Exhaustion on AWS</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>RDS vs DynamoDB: The Benchmark Isn&#8217;t the Decision</title>
		<link>https://john-nessime.com/blog/technical-guides/rds-vs-dynamodb-access-patterns/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Tue, 29 Sep 2026 06:05:28 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[Databases]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Amazon Aurora]]></category>
		<category><![CDATA[Amazon RDS]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[AWS]]></category>
		<category><![CDATA[AWS Lambda]]></category>
		<category><![CDATA[Capacity Planning]]></category>
		<category><![CDATA[Connection Pooling]]></category>
		<category><![CDATA[Cost Optimization]]></category>
		<category><![CDATA[Data Modeling]]></category>
		<category><![CDATA[DynamoDB]]></category>
		<category><![CDATA[Hot Partition]]></category>
		<category><![CDATA[Over-Engineering]]></category>
		<category><![CDATA[PostgreSQL]]></category>
		<category><![CDATA[Provisioned Throughput]]></category>
		<category><![CDATA[Schema Design]]></category>
		<category><![CDATA[Serverless]]></category>
		<category><![CDATA[Throttling]]></category>
		<category><![CDATA[Workload Shape]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=738</guid>

					<description><![CDATA[<p>Latency charts don't decide this. The query nobody wrote down at the start does. How to build an access pattern inventory, where DynamoDB and RDS each genuinely win and hurt, a six-question decision procedure, and the arguments on both sides that fall apart under a real workload.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/rds-vs-dynamodb-access-patterns/">RDS vs DynamoDB: The Benchmark Isn&#8217;t the Decision</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Someone drops a benchmark link into the design review. Flat latency line for DynamoDB, a rising one for Postgres, and the data store is chosen before anybody has described what the application does.</p>



<p class="wp-block-paragraph">That argument settles architecture decisions all the time, and it&#8217;s the wrong argument. Both services return a primary-key lookup faster than your network hop and your ORM overhead. What hurts six months later is the query nobody wrote down at the start, and no benchmark will warn you about it.</p>



<p class="wp-block-paragraph">So this post skips the latency charts. It covers the access pattern inventory you write before picking anything, where each service genuinely wins and genuinely hurts, a decision procedure you can run in an afternoon, and the arguments on both sides that fall apart on contact with a real workload.</p>



<h2 class="wp-block-heading">Why the benchmark answers a question you don&#8217;t have</h2>



<p class="wp-block-paragraph">A benchmark measures a fixed operation under fixed conditions. Your application is not a fixed operation. It&#8217;s a set of reads and writes that grows every time somebody asks a new question about the data.</p>



<p class="wp-block-paragraph">The failure mode that decides RDS vs DynamoDB is almost never throughput. It&#8217;s a product manager asking, eight months in, for every account that hasn&#8217;t logged in this quarter, broken down by plan tier. On Postgres that&#8217;s an awkward query and maybe an index. On DynamoDB, if you didn&#8217;t model for it, it&#8217;s a full table scan, a new global secondary index, a backfill, and a standing write-capacity tax to keep it in sync.</p>



<p class="wp-block-paragraph">That asymmetry is the whole decision. A relational database defers the modeling cost and charges a little on every query for the flexibility. DynamoDB front-loads it: design keys around known patterns, get cheap predictable reads for exactly those, and pay dearly for anything else.</p>



<h2 class="wp-block-heading">Write the access pattern inventory first</h2>



<p class="wp-block-paragraph">Before comparing anything, write the list. Every read and write the application performs, in plain language, with the shape of the operation and how fast it needs to be. This much is enough:</p>



<pre class="wp-block-code"><code>PATTERN                                SHAPE           FREQ    LATENCY
get order by id                        point read      high    p99 &lt; 50ms
list a customer's orders, newest first range read      high    p99 &lt; 100ms
orders by status, across customers     cross-entity    low     seconds fine
revenue by product category, monthly   aggregate       low     minutes fine
find orders by partial address         text match      medium  p99 &lt; 300ms
mark order shipped + write audit row   multi-item write high   p99 &lt; 100ms</code></pre>



<p class="wp-block-paragraph">Now read the SHAPE column. If nearly every row is a point or range read, and the exceptions tolerate latency, DynamoDB is a live option. If the column is full of cross-entity reads, aggregates and text matching, you&#8217;re describing a relational workload and can stop shopping.</p>



<h3 class="wp-block-heading">The rows people leave out</h3>



<p class="wp-block-paragraph">The inventory is only useful if it&#8217;s honest, and the omissions are predictable:</p>



<ul class="wp-block-list">
<li>The admin back office. Support staff search by email, phone, partial name, anything. Ad hoc queries, and they never appear in the first inventory.</li>



<li>Reporting. Someone will want a monthly rollup. Decide now whether it lives in the operational store or streams out to a warehouse.</li>



<li>Deletion. Regulatory deletes cascade across entities. In a normalized schema that&#8217;s a transaction. In a denormalized single table it&#8217;s a fan-out you have to write and test yourself.</li>



<li>Backfills. Every schema change becomes a migration job when no schema is enforced at the storage layer.</li>
</ul>



<p class="wp-block-paragraph">Write them down even if the answer is &#8220;we&#8217;ll handle it elsewhere.&#8221; A pattern you consciously exile to a warehouse is fine. One you forgot is a rewrite.</p>



<h2 class="wp-block-heading">Where DynamoDB wins, and where it doesn&#8217;t</h2>



<p class="wp-block-paragraph">Give it its genuine case first, because it&#8217;s a strong one.</p>



<h3 class="wp-block-heading">Where it wins</h3>



<ul class="wp-block-list">
<li><strong>Connection model.</strong> It&#8217;s an HTTPS API with SigV4 auth, so there&#8217;s no pool to exhaust. That&#8217;s why it pairs cleanly with Lambda. A relational database behind high Lambda concurrency needs RDS Proxy or a pooler, which is another component you operate.</li>



<li><strong>Predictable reads at any size.</strong> A query against a well-chosen partition key costs the same at ten thousand items or ten billion. Nothing in Postgres gives you that for free.</li>



<li><strong>Idle cost.</strong> On-demand mode bills per request unit consumed, so a table with no traffic costs storage and nothing else. Useful for tenant-per-table designs and low-traffic side services.</li>



<li><strong>Operational surface.</strong> No minor version upgrades, no vacuum, no failover window, no parameter groups to argue about.</li>
</ul>



<p class="wp-block-paragraph">The query API is narrow on purpose. Give it an exact partition key plus an optional sort key condition, and it returns an ordered slice of an item collection:</p>



<pre class="wp-block-code"><code>aws dynamodb query 
  --table-name AppData 
  --key-condition-expression "PK = :pk AND begins_with(SK, :sk)" 
  --expression-attribute-values '{":pk":{"S":"CUSTOMER#42"},":sk":{"S":"ORDER#"}}'</code></pre>



<p class="wp-block-paragraph">One customer&#8217;s orders, in sort order, in one request, no join. If that call shape covers most of your inventory, you&#8217;re looking at the right service.</p>



<h3 class="wp-block-heading">Where it doesn&#8217;t</h3>



<ul class="wp-block-list">
<li><strong>Anything you didn&#8217;t model.</strong> New patterns mean new keys or a new index, and a new global secondary index means a backfill plus write capacity on every future write.</li>



<li><strong>Filtering is not querying.</strong> A <code>FilterExpression</code> runs after the read, so you pay capacity for every item examined and only save bandwidth. A filter that discards most of what it reads is a design smell.</li>



<li><strong>Aggregates.</strong> There is no <code>GROUP BY</code> and no <code>COUNT(*)</code> over a table. You maintain aggregates yourself, usually with DynamoDB Streams feeding a Lambda that updates a counter item, which means you own the correctness problem when that consumer fails.</li>



<li><strong>Hard physical limits.</strong> An item caps at 400 KB. A Query or Scan page returns at most 1 MB before you paginate. A single partition sustains roughly 3,000 read and 1,000 write units per second, so a hot key throttles while the table sits well under its quota.</li>



<li><strong>Index budget.</strong> Global secondary indexes default to twenty per table; local secondary indexes cap at five and can only be defined at table creation. You can&#8217;t index your way out of a bad key design forever.</li>
</ul>



<h2 class="wp-block-heading">Where RDS wins, and where it doesn&#8217;t</h2>



<h3 class="wp-block-heading">Where it wins</h3>



<ul class="wp-block-list">
<li><strong>Unknown future queries.</strong> The entire point. Joins, aggregates, window functions, ad hoc analysis, and a new index in one statement when something turns out slow.</li>



<li><strong>Constraints the database enforces.</strong> Foreign keys, uniqueness, check constraints, transactions across arbitrary tables. In DynamoDB each of those is application code you write and maintain.</li>



<li><strong>Extensions.</strong> PostGIS for geospatial, pgvector for embeddings, full-text search built in, all against the same rows in the same transaction.</li>



<li><strong>Portability.</strong> Postgres and MySQL run on RDS, in a container, or on a plain VPS from a provider like Contabo or InterServer, so migrating off is an export and an import. DynamoDB&#8217;s data model doesn&#8217;t port without rewriting your data access layer.</li>



<li><strong>Everyone already knows SQL.</strong> Underrated on a small team. Teaching four developers single-table design costs real review time.</li>
</ul>



<h3 class="wp-block-heading">Where it doesn&#8217;t</h3>



<ul class="wp-block-list">
<li><strong>Connections under serverless concurrency.</strong> Each Lambda execution environment opens its own connection, and the database refuses new ones once <code>max_connections</code> is reached. RDS Proxy multiplexes to fix that, but some operations pin a client to a backend connection and pinned connections aren&#8217;t reused. Watch <code>DatabaseConnectionsCurrentlySessionPinned</code> before trusting the pool.</li>



<li><strong>Instances are always on.</strong> A provisioned instance bills whether or not anyone queries it. Aurora Serverless v2 changes that with a minimum of zero capacity units on a supported engine version, but resuming from paused is slow enough that the first user after an idle period feels it. AWS documents resume as taking roughly fifteen seconds.</li>



<li><strong>Vertical scaling has a ceiling.</strong> Writes go to one primary and read replicas help reads only. Sharding is a project, not a setting.</li>



<li><strong>Maintenance is yours.</strong> Version upgrades, failover testing, long transactions blocking vacuum, connection storms on restart. Managed does not mean absent.</li>
</ul>



<p class="wp-block-paragraph">If you want a database that hibernates properly, it lives in the scaling configuration and the idle timeout is yours to set:</p>



<pre class="wp-block-code"><code>aws rds create-db-cluster 
  --db-cluster-identifier app-cluster 
  --engine aurora-postgresql 
  --serverless-v2-scaling-configuration 
      MinCapacity=0,MaxCapacity=4,SecondsUntilAutoPause=600 
  --manage-master-user-password</code></pre>



<p class="wp-block-paragraph">A minimum of zero only works on engine versions supporting automatic pause and resume, so check yours before planning a budget around it. If the cluster refuses the setting, that&#8217;s usually why.</p>



<h2 class="wp-block-heading">A decision procedure for RDS vs DynamoDB</h2>



<p class="wp-block-paragraph">Run these in order against the inventory you wrote. The first clear answer wins.</p>



<ol class="wp-block-list">
<li><strong>Can you enumerate the access patterns, and are they stable?</strong> If the product is still moving and nobody can say what the admin tools will need, pick the store that tolerates being wrong. That&#8217;s RDS.</li>



<li><strong>Does any pattern need a cross-entity aggregate on the hot path?</strong> A user-facing screen needing a count or sum across entities in under a second, with no way to precompute it, means RDS.</li>



<li><strong>Is there a natural partition key with even distribution?</strong> Tenant ID, user ID, device ID. If traffic concentrates on one key, the per-partition ceiling finds you regardless of capacity mode. No good key means no DynamoDB.</li>



<li><strong>What&#8217;s the concurrency shape of the compute tier?</strong> Thousands of short-lived Lambda invocations argue for DynamoDB. A few long-running containers with a warm pool remove the strongest argument against RDS.</li>



<li><strong>Does the workload go quiet?</strong> Bursty and mostly idle favors on-demand DynamoDB or a scale-to-zero Aurora cluster. Steady traffic makes provisioned capacity or a right-sized instance cheaper.</li>



<li><strong>Who maintains this in a year?</strong> If it&#8217;s one part-time developer, weight familiarity heavily. A design nobody on the team can debug is a liability whatever the benchmark said.</li>
</ol>



<h2 class="wp-block-heading">The cost model nobody actually models</h2>



<p class="wp-block-paragraph">The two bill on axes that don&#8217;t compare. RDS charges instance hours, storage, and depending on engine and storage type, I/O, busy or not. DynamoDB charges request units and storage, so an idle table is nearly free and a chatty one isn&#8217;t.</p>



<p class="wp-block-paragraph">The number that matters is request units per user action, and almost nobody calculates it. Reads bill in 4 KB increments and writes in 1 KB increments, strongly consistent reads cost twice what eventually consistent ones do, and transactional operations double again. Every index you project into consumes write units on every write that touches it. A screen firing six queries plus an audit write can cost many times the naive estimate.</p>



<p class="wp-block-paragraph">Work it out per action, multiply by real traffic, and sanity check the instance side with my <a href="https://john-nessime.com/tools/aws-cost-estimator/">AWS cost estimator</a> before the decision hardens. AWS recommends on-demand as the default capacity mode for most workloads, but steady predictable traffic is still cheaper provisioned, and you can flip between them:</p>



<pre class="wp-block-code"><code># which mode is this table on?
aws dynamodb describe-table --table-name AppData 
  --query "Table.BillingModeSummary.BillingMode"

# what is it actually consuming?
aws cloudwatch get-metric-statistics 
  --namespace AWS/DynamoDB 
  --metric-name ConsumedWriteCapacityUnits 
  --dimensions Name=TableName,Value=AppData 
  --statistics Sum --period 3600 
  --start-time $(date -u -d '1 day ago' +%Y-%m-%dT%H:%M:%SZ) 
  --end-time $(date -u +%Y-%m-%dT%H:%M:%SZ)</code></pre>



<p class="wp-block-paragraph">Switching billing mode is rate limited, so treat it as a planned change rather than an incident-time knob.</p>



<h2 class="wp-block-heading">Arguments that don&#8217;t survive contact</h2>



<ul class="wp-block-list">
<li><strong>&#8220;DynamoDB scales infinitely.&#8221;</strong> The service does. Your partition key might not. Uneven access concentrates load on one partition, and adaptive capacity only helps within limits.</li>



<li><strong>&#8220;Postgres can&#8217;t handle our scale.&#8221;</strong> Usually said before anyone has run <code>EXPLAIN (ANALYZE, BUFFERS)</code> on the slow query. A missing index and an N+1 in the ORM explain most claims of outgrowing relational.</li>



<li><strong>&#8220;We&#8217;ll add the indexes later.&#8221;</strong> True in Postgres, expensive in DynamoDB. A new global secondary index backfills across the whole table and then taxes every subsequent write.</li>



<li><strong>&#8220;Serverless database means no operations.&#8221;</strong> You trade instance operations for capacity and key-design operations. Throttling, hot partitions and stream consumer lag all need dashboards, exactly like a relational instance does.</li>



<li><strong>&#8220;We can just use both.&#8221;</strong> Sometimes right, often a way to avoid deciding. Two stores means two consistency models, two restore procedures, and a sync path that becomes the least reliable component you own.</li>
</ul>



<h2 class="wp-block-heading">How I&#8217;d decide</h2>



<p class="wp-block-paragraph">My default for a new application with an unfinished product definition is managed Postgres. Not because it&#8217;s better, but because being wrong about a relational schema costs an afternoon and being wrong about a key design costs a migration.</p>



<p class="wp-block-paragraph">I&#8217;d reach for DynamoDB first when the workload is a small closed set of patterns keyed off one identifier, with high write volume, tolerance for eventual consistency, and concurrency that would flatten a connection pool. Session stores, telemetry ingestion, per-user feeds, idempotency keys, feature flags. It&#8217;s excellent at all of those.</p>



<p class="wp-block-paragraph">The hybrid worth doing is narrow: relational as the system of record, DynamoDB for one high-volume pattern that joins nothing. The hybrid to avoid splits related entities across both stores and reconciles them in application code.</p>



<p class="wp-block-paragraph">Whichever you land on, instrument it on day one. Throttled requests and consumed capacity on the DynamoDB side, connection counts and slow queries on the relational side, on a CloudWatch or Grafana Cloud dashboard someone actually opens. If you&#8217;re restricting item-level access with IAM condition keys such as <code>dynamodb:LeadingKeys</code>, run the policy through my <a href="https://john-nessime.com/tools/aws-iam-policy-checker/">IAM policy checker</a> before it ships.</p>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Is DynamoDB always faster than RDS?</h3>



<p class="wp-block-paragraph">For a primary-key lookup at large scale its latency is more consistent, because that operation&#8217;s cost doesn&#8217;t grow with table size. For a query it wasn&#8217;t modeled for it can be far slower, because the fallback is a scan. Speed depends on the match between query and key design, not on the service.</p>



<h3 class="wp-block-heading">Can I run analytics on DynamoDB?</h3>



<p class="wp-block-paragraph">Not directly, and you shouldn&#8217;t try. Export the table to S3 or stream changes out, then query with Athena or load a warehouse. Treat operational and analytical stores as separate systems.</p>



<h3 class="wp-block-heading">What about Aurora Serverless as the middle ground?</h3>



<p class="wp-block-paragraph">It solves idle cost, not connections or write scaling. If you&#8217;re considering DynamoDB because of Lambda concurrency or single-primary write limits, Aurora Serverless doesn&#8217;t address that. If it&#8217;s because you&#8217;re paying for an idle dev database, it does.</p>



<h3 class="wp-block-heading">How do I migrate from DynamoDB to RDS if I get it wrong?</h3>



<p class="wp-block-paragraph">Export to S3, reshape denormalized items into normalized tables, load, and dual-write during cutover. The data movement is routine. The hard part is that your application code assumed a key-value access shape everywhere, so the rewrite lands in the service layer, not the pipeline.</p>



<h3 class="wp-block-heading">Is DynamoDB cheaper than RDS for a small application?</h3>



<p class="wp-block-paragraph">Usually yes when traffic is low and bursty, because on-demand charges per request against an idle table while an instance bills continuously. The advantage narrows as traffic steadies and reverses once throughput justifies provisioned capacity or a reserved instance.</p>



<h3 class="wp-block-heading">Do I need RDS Proxy if I use Lambda with Postgres?</h3>



<p class="wp-block-paragraph">At meaningful concurrency, yes. Below that, a direct connection reused across invocations is fine, and the proxy adds cost plus a pinning failure mode. Add it when connection counts approach your limit, not preemptively in development.</p>



<h2 class="wp-block-heading">Conclusion</h2>



<p class="wp-block-paragraph">The one thing worth remembering about RDS vs DynamoDB: you&#8217;re not choosing a performance profile, you&#8217;re choosing when to pay the modeling cost. DynamoDB takes it up front and pays you back in predictability. A relational database lets you defer it and charges interest later in query complexity.</p>



<p class="wp-block-paragraph">Write the access pattern inventory. Be honest about the admin screens and the reporting nobody has asked for yet. If the list is short, stable and keyed on one identifier, DynamoDB will serve you for years. If it isn&#8217;t, take the relational database and spend the saved effort on indexes and connection handling.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a second opinion on the data store decision?</h2>



<p class="wp-block-paragraph">Cheap to review now, expensive to revisit later. Things I help with:</p>



<ul class="wp-block-list">
<li>Building the access pattern inventory with your team and pressure-testing it against the queries your roadmap implies</li>



<li>Reviewing a DynamoDB key and index design before it ships, including partition key distribution and index write amplification</li>



<li>Diagnosing throttling and hot partitions on an existing table, and whether the fix is a key change, a capacity change or a cache</li>



<li>Sizing RDS or Aurora for a serverless compute tier, including connection handling, RDS Proxy configuration and pinning</li>



<li>Modeling the cost of both options against your real request volume instead of a rule of thumb</li>



<li>Planning a migration in either direction, with a dual-write cutover and a rollback that works</li>
</ul>



<p class="wp-block-paragraph">Send me your access pattern list, a slow query plan, or your throttling metrics and I&#8217;ll tell you what I&#8217;d do with it.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/rds-vs-dynamodb-access-patterns/">RDS vs DynamoDB: The Benchmark Isn&#8217;t the Decision</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
