Azure Databases

Connection Storms on MySQL and PostgreSQL Flexible Server: Pool Exhaustion and max_connections

At 13:02 on the first day of a sale, the checkout API starts throwing ERROR 1040 (HY000): Too many connections on MySQL — or FATAL: sorry, too many clients already on PostgreSQL. The database CPU sits at 22%. Memory is fine. IOPS are nowhere near the cap. Nothing about the server looks unhealthy, yet every new request to open a connection is refused, and the on-call instinct — scale up the SKU — does nothing, because compute was never the constraint. This is a connection storm: your application layer opened more concurrent connections than the engine is allowed to accept, and the ceiling it hit is max_connections — a number that, on Azure Database for MySQL Flexible Server and Azure Database for PostgreSQL Flexible Server, is derived from the memory of the SKU you chose, not from how busy the box is.

This is the diagnostic playbook for that failure. We treat “too many connections” not as one bug but as a family of root causes — a serverless front end that fans out to hundreds of instances, a driver pool multiplied by instance count, idle connections that never get returned, and on PostgreSQL the choice to connect on the raw engine port instead of through the built-in PgBouncer pooler. Each has a precise way to confirm it (an exact az command, a SHOW PROCESSLIST / pg_stat_activity query, an Azure metric) and a precise fix. Because PostgreSQL forks a heavyweight backend process per connection while MySQL spins a thread per connection, the mechanism differs between the two engines and the right fix differs with it — which is why this article keeps both side by side and tells you which lever pulls on which engine.

By the end you will stop guessing. When the pager goes off you will know whether you face a max_connections ceiling set by a too-small SKU, an application pool that multiplies out of control, idle-in-transaction connections holding slots hostage, a pooler you forgot to turn on, or genuine load — and you will know which of those a bigger SKU actually fixes (one) and which it merely hides (the rest). Knowing which within ninety seconds is what separates a five-minute incident from a two-hour one.

What problem this solves

A managed database hands you a connection string and the comfortable illusion of an infinite endpoint. That illusion holds until concurrency climbs, and then the endpoint reveals a hard ceiling unrelated to query throughput. Both Flexible Server engines run on a single VM you sized, and each accepted connection costs real RAM on it — a PostgreSQL backend is a forked process of several MB; a MySQL connection is a thread plus its buffers. To stop a storm from eating all the memory and crashing the engine, the platform caps simultaneous connections at max_connections, a cap that scales with the SKU’s memory. Hit it and the engine does the safe thing: it refuses new connections rather than dying. From the application side that refusal looks like a total outage even though the database is healthy.

What breaks without this knowledge is predictable and expensive. The on-call engineer scales the SKU up one notch, the storm pauses for an hour while the slightly higher max_connections absorbs it, then it returns worse at the next peak — and now the bill is higher for no structural gain. Or someone raises max_connections by hand past what the RAM can support, the engine starts OOM-killing backends, and a clean “connection refused” becomes random query failures and a far harder incident. Or, on PostgreSQL, the team never enables the built-in PgBouncer and connects thousands of short-lived serverless connections straight to port 5432, exhausting the engine while a pooler that ships in the box sits unused.

Who hits this: any team with a high-fan-out front end on a relational database. It bites hardest on serverless and event-driven workloads — Azure Functions and Container Apps that scale to hundreds of instances, each opening its own pool — on microservices where every service holds a pool to the same database, and on apps with a misconfigured pool (too large, never released, leaked through unclosed connections). The fix is almost never “scale up.” It is “pool connections correctly, put a transaction pooler in front, and size the pool to the backends the SKU can actually afford.”

To frame the whole field before the deep dive, here is the difference that drives every later decision — how each engine spends a connection, and therefore why the same symptom needs a different fix:

Engine One connection costs Default pooler Engine port Pooler port Error when exhausted
MySQL Flexible Server A thread + per-thread buffers (lighter, but still capped) None built in (use driver pool / external proxy) 3306 n/a (no in-box pooler) ERROR 1040 (HY000): Too many connections
PostgreSQL Flexible Server A forked backend process (several MB each, heavier) Built-in PgBouncer (transaction mode) 5432 6432 FATAL: sorry, too many clients already

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should already be comfortable standing up a Flexible Server — choosing a tier (Burstable / General Purpose / Business Critical), a SKU, and a networking model — and making a first connection. If that is fuzzy, work through Deploy Azure Database for MySQL Flexible Server: Networking, Backups and First Connection first; the create-time decisions there (especially the permanent public-vs-private networking choice) are upstream of everything here. You should know how to run az in Cloud Shell, read JSON output, and connect with the mysql or psql client. Familiarity with TCP connections, the idea of a connection pool, and basic SQL is assumed; you do not need to be a DBA.

This sits in the Databases / Troubleshooting track. It is the connection-management companion to the broader relational-troubleshooting playbook in Troubleshooting Azure SQL: Connectivity, Timeouts, Throttling and Blocking — Azure SQL Database handles connection limits differently (per-database/pool caps tied to vCores/DTUs), but the diagnostic discipline is identical. The observability you need to watch active connections lives in Azure Monitor and Application Insights for Observability. And because the outbound half of a storm often originates in your App Service tier, Azure NAT Gateway: Setup and Outbound Connectivity and Private Endpoint vs Service Endpoint are the network levers you will reach for.

A quick map of who confirms what during a connection-storm incident, so you call the right person fast:

Layer What lives here Who usually owns it How it causes a storm
Application / driver The connection pool, pool size, leak handling App / dev team Oversized pool, unclosed connections, pool-per-instance fan-out
Front-end scale unit Functions/Container Apps instance count App + platform Each instance opens its own pool → connections × instances
Pooler (PgBouncer) Transaction pooling on 6432 (Postgres only) Platform + DBA Not enabled, or pool sized too large (re-creates the storm)
Engine (primary) max_connections, reserved slots, backends DBA Ceiling derived from SKU memory; exhaustion → refuse
Read replicas Each has its own max_connections DBA Pools split across replicas multiply total connections
Outbound (SNAT / NAT GW) App Service egress to the DB Platform + network Source-port exhaustion looks like a DB connection failure

Core concepts

Five mental models make every later diagnosis obvious.

A connection is a server-side resource with a memory cost, and the cap protects the box. PostgreSQL forks a separate backend process per connection; MySQL spawns a thread per connection. Either way the server holds per-connection state — buffers, work memory, a slot in an internal table — that consumes RAM whether or not a query is running. To keep a flood from exhausting memory and crashing the engine, both Flexible Server engines enforce max_connections, the hard ceiling on simultaneous connections, and refuse the next one past it. That refusal is a feature — the engine choosing to stay alive — which is exactly why it correlates with concurrency, not CPU.

max_connections is derived from the SKU’s memory, not chosen freely. On both engines the default and maximum allowed values scale with the memory of the compute SKU — a 2 GB Burstable server is allowed far fewer connections than a 32 GB General Purpose one, because the formula reserves RAM per connection. You can nudge the parameter but cannot set it higher than memory supports, and pushing it toward the ceiling trades connection headroom for the memory your queries need. The honest fixes are fewer connections (pooling) or more memory (a bigger SKU), in that order.

Idle connections still cost a slot. A connection that opened, ran one query, and now sits idle still occupies a slot and its memory. PostgreSQL has a dangerous variant — idle in transaction — where a connection opened a transaction and never committed; it holds its slot and blocks vacuum and locks rows. A pool of 200 connections that are 95% idle is indistinguishable, to max_connections, from 200 busy ones: the ceiling counts open sockets, not active queries. That is why an oversized-but-idle pool exhausts the engine as effectively as real load.

Pooling moves the bottleneck from the engine to a cheap multiplexer. A connection pool keeps a small set of warm connections and lends them to requests, so a thousand requests share, say, fifty database connections. There are two places to pool: the driver (each app process keeps its own pool) and a server-side pooler (one shared pool in front of the engine). Driver pooling alone breaks under fan-out — a hundred instances each holding a fifty-connection pool is five thousand connections. The robust answer is a transaction-mode pooler in front of the engine; on PostgreSQL Flexible Server that pooler, PgBouncer, is built in on port 6432. MySQL Flexible Server has no in-box pooler, so you pool correctly in the driver and, for heavy fan-out, front it with an external proxy.

Transaction pooling is cheap but breaks session state. A transaction-mode pooler returns the server connection to the pool at the end of every transaction, so a few backends serve many clients. The trade: anything assuming a stable server connection across transactions breaks — server-side prepared statements, session variables, LISTEN/NOTIFY, temp tables, advisory locks. You either avoid those features or configure the driver not to rely on a persistent session. This is why enabling PgBouncer occasionally produces “prepared statement does not exist” errors and how to fix them without abandoning pooling.

The vocabulary in one table

Before the deep sections, pin down every moving part. The glossary at the end repeats these for lookup; this table is the mental model side by side:

Concept One-line definition Where it lives Why it matters to a storm
max_connections Hard ceiling on simultaneous connections Server parameter (derived from SKU memory) The wall you hit; exhaustion → refuse
Backend / thread The server-side process (PG) or thread (MySQL) per connection On the server VM Each one costs RAM and a slot
Connection pool (driver) Warm connections reused inside one app process The app Right-sized → few connections; oversized → storm
PgBouncer Built-in transaction pooler (PostgreSQL only) On the server VM, port 6432 Multiplexes many clients onto few backends
Transaction pooling Return connection to pool at end of each transaction PgBouncer pool_mode Huge multiplexing; breaks session state
Idle in transaction Open transaction, no commit, holding a slot (PG) A backend’s state Holds slot + blocks vacuum/locks
Reserved connections Slots the engine holds back for superuser/admin Inside max_connections Your apps get fewer than the raw number
SNAT port Outbound source-port mapping from App Service Per App Service instance Exhaustion mimics a DB connection failure
Read replica An async copy with its own max_connections A separate server Pools split across replicas multiply totals

How max_connections is derived from the SKU

The single most important fact in this article: on both Flexible Server engines, max_connections is a function of the SKU’s memory. You do not invent the number; the platform sets a default (and a ceiling) proportional to RAM, because each connection reserves memory. Bigger SKU → more memory → more allowed connections. This is why “the database is at 20% CPU but refusing connections” is not a contradiction: the limiting resource is the connection-slot budget, paid for in RAM, not CPU.

The practical consequences fall out immediately:

So the first move in any storm is to read the effective max_connections for the running SKU, not to assume a number — from the live server on either engine.

For MySQL, query the engine directly or read the server parameter:

# Read the effective max_connections the server is enforcing (MySQL)
mysql -h mysql-shop-prod.mysql.database.azure.com -u dbadmin -p \
  -e "SHOW VARIABLES LIKE 'max_connections';"
# Same value via the management plane (server parameter)
az mysql flexible-server parameter show \
  --resource-group rg-shop-prod --server-name mysql-shop-prod \
  --name max_connections \
  --query "{name:name, value:value, default:defaultValue, isConfigurable:isConfigPendingRestart}" -o table

For PostgreSQL, the same two paths:

# Effective max_connections (PostgreSQL) — note PG reserves some for superusers
psql "host=pg-shop-prod.postgres.database.azure.com port=5432 dbname=postgres user=pgadmin sslmode=require" \
  -c "SHOW max_connections;"
az postgres flexible-server parameter show \
  --resource-group rg-shop-prod --server-name pg-shop-prod \
  --name max_connections \
  --query "{name:name, value:value, default:defaultValue}" -o table

The reserved-slots subtlety bites people who size pools to the raw number. PostgreSQL holds back a small number of connections (superuser_reserved_connections, plus on Flexible Server the platform’s own monitoring/management connections) so an admin can always log in to fix a storm. Your application therefore gets fewer usable slots than max_connections prints. Always size your total app pool below the effective ceiling, leaving headroom.

How to reason about the ceiling per tier — the pattern, not invented exact numbers (read the live value; it scales with memory):

Compute tier Typical memory range max_connections posture Right use Connection risk
Burstable (B-series) 0.5–16 GB Low default, low ceiling (memory-bound) Dev/test, one low-traffic app Exhausts almost instantly behind fan-out
General Purpose 4–256 GB Moderate–high, scales with memory Production default Finite; a storm can still exhaust it
Business Critical (Memory Optimized) High memory per vCore Highest ceilings Connection-heavy / memory-heavy prod Pool correctly even here — finite

Two rules that follow:

Rule Why Anti-pattern it prevents
Read the effective max_connections live before sizing pools It is SKU-derived, not a constant Sizing to a number from an old tier or a blog
Leave headroom below the ceiling (reserved + admin + replicas) Superuser/monitoring slots are carved out of the total Maxing app pools to the printed number, locking yourself out

Anatomy of a connection storm: the six root causes

A “too many connections” error has six distinct origins. Scan the matrix, then read the detail for whichever row matches your evidence:

# Root cause Tell-tale signal Confirm with Real fix Band-aid that masks it
1 SKU max_connections ceiling too low Even modest concurrency hits the wall SHOW max_connections vs active count Scale SKU and/or pool Restart (storm returns at peak)
2 Application pool oversized or leaked Connections grow and never fall SHOW PROCESSLIST / pg_stat_activity Right-size pool; fix leaks Scale SKU (hides the leak)
3 Idle / idle-in-transaction holding slots Many Sleep / idle in transaction rows pg_stat_activity.state Lower idle timeout; commit promptly Bigger SKU (still leaks slots)
4 No pooler under high fan-out Connections ≈ instances × pool size Count distinct client IPs / app instances PgBouncer (PG) / proxy + small pools (MySQL) Scale out (multiplies connections)
5 Pools split across read replicas Total > any single server’s max_connections Sum active across primary + replicas Pool per role; route reads deliberately Raise per-server cap
6 Genuine load exceeds capacity All connections busy with real queries Active (not idle) ≈ ceiling Scale SKU; add replicas; cache Pooling alone (real demand remains)

This is the core of the playbook. Note which cause a bigger SKU actually fixes: only #1 (genuinely too-small ceiling) and #6 (genuine load) respond to scaling up. The other four are application or architecture bugs that a bigger SKU only postpones — and that distinction is the whole game.

Cause 1 — The SKU’s max_connections ceiling is simply too low

You deployed a Burstable SKU (often Standard_B1ms, 1 vCore / 2 GB) because it was cheap, then put a real workload behind it. The memory-derived max_connections on that tier is small, and even moderate concurrency walks straight into it. The database is healthy; the ceiling is just low for what you are asking of it.

Confirm. Read the effective ceiling and the current active count, and compare:

# PostgreSQL: ceiling vs current open connections
psql "host=pg-shop-prod.postgres.database.azure.com port=5432 dbname=postgres user=pgadmin sslmode=require" -c "
SELECT (SELECT setting::int FROM pg_settings WHERE name='max_connections') AS max_conn,
       count(*) AS current_conn
FROM pg_stat_activity;"
# MySQL: ceiling vs currently used + the historical high-water mark
mysql -h mysql-shop-prod.mysql.database.azure.com -u dbadmin -p -e "
SHOW VARIABLES LIKE 'max_connections';
SHOW STATUS LIKE 'Threads_connected';
SHOW STATUS LIKE 'Max_used_connections';"

If Max_used_connections (MySQL) is pinned at or near max_connections, or the PostgreSQL current count is bumping the ceiling, the wall is real. Confirm the metric in Azure Monitor too — both engines expose an active-connections metric you should alert on:

# Active-connections metric (works for both engines; metric name shown for MySQL Flexible Server)
az monitor metrics list \
  --resource $(az mysql flexible-server show -g rg-shop-prod -n mysql-shop-prod --query id -o tsv) \
  --metric active_connections --interval PT1M --aggregation Maximum -o table

Fix. If you are genuinely on a too-small tier and already pool well, scale the SKU up — more memory means a higher max_connections:

# Scale MySQL compute (brief restart) — moves to a SKU with more memory and a higher ceiling
az mysql flexible-server update --resource-group rg-shop-prod --name mysql-shop-prod \
  --sku-name Standard_D2ds_v4
resource mysql 'Microsoft.DBforMySQL/flexibleServers@2023-12-30' = {
  name: 'mysql-shop-prod'
  location: location
  sku: { name: 'Standard_D2ds_v4', tier: 'GeneralPurpose' }  // more RAM → higher max_connections
  properties: {
    version: '8.0.21'
    administratorLogin: 'dbadmin'
  }
}

But before reaching for the slider, confirm the ceiling is the real problem and not cover for an oversized pool (Cause 2). A small SKU exhausted at 95% idle connections does not need more memory — it needs fewer connections.

Cause 2 — The application pool is oversized or leaking connections

The most common real cause. The pool’s maximum size is far higher than you need, or connections are leaked — opened and never returned because of an unclosed handle, an exception path that skips Dispose/close, or an untuned framework default. Either way the open-connection count climbs and never falls, exhausting the ceiling with connections that are mostly doing nothing.

Confirm. Look at who is holding connections and what state they are in. On MySQL, SHOW PROCESSLIST (or the fuller information_schema.PROCESSLIST):

-- MySQL: how many connections, grouped by state — a sea of 'Sleep' means idle/leaked
SELECT COMMAND, COUNT(*) AS n, AVG(TIME) AS avg_secs
FROM information_schema.PROCESSLIST
GROUP BY COMMAND
ORDER BY n DESC;

On PostgreSQL, pg_stat_activity is the equivalent and is more informative:

-- PostgreSQL: connections by state and by client app — find the pool that won't let go
SELECT state, application_name, count(*) AS n,
       max(now() - state_change) AS longest_in_state
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY state, application_name
ORDER BY n DESC;

A large idle count that never shrinks, concentrated under one application_name, is a leaked or oversized pool. If the count equals (instances × pool max), it is a sizing problem (see Cause 4); if it grows unbounded over time, it is a leak.

Fix. First, cap the pool in the driver to a sane number (see the sizing section below) — a pool of 200 per process is almost never right. Second, fix the leak: ensure every connection is closed on every path. The pattern differs by stack but the principle is identical — acquire, use, release deterministically:

// .NET (Npgsql / MySqlConnector): 'using' guarantees the connection returns to the pool
await using var conn = new NpgsqlConnection(connectionString);   // pooled by default
await conn.OpenAsync();
await using var cmd = new NpgsqlCommand("SELECT 1", conn);
await cmd.ExecuteScalarAsync();
// conn is returned to the pool here, even on exception

Then set explicit pool bounds in the connection string rather than trusting defaults:

Stack Pool-size knob Sensible starting value Notes
.NET Npgsql (PostgreSQL) Maximum Pool Size / Minimum Pool Size 20–50 max per process Default max is 100 — usually too high under fan-out
.NET MySqlConnector MaximumPoolSize / MinimumPoolSize 20–50 max per process Pool per process; multiply by instance count
Java HikariCP maximumPoolSize cores × 2 to start, then measure Hikari’s own guidance: small pools beat big ones
Node pg / mysql2 max (pool size) 10–20 per instance Each Lambda/Function instance has its own pool
Python SQLAlchemy pool_size + max_overflow pool_size=10, max_overflow=5 max_overflow adds temporary connections — count them in

The rule that prevents the storm: total connections = pool max × app instances, and that product must stay comfortably under the effective max_connections. A pool correct for one instance becomes a storm at forty.

Cause 3 — Idle and idle-in-transaction connections hold slots hostage

A connection can be open, occupying a max_connections slot, and doing absolutely nothing. On MySQL these show as Sleep; on PostgreSQL as idle. Worse, on PostgreSQL a connection can be idle in transaction — it ran BEGIN, did some work, and then the application stalled (a slow downstream call, a bug, a debugger breakpoint) without COMMIT/ROLLBACK. That connection holds its slot, holds any locks it took, and blocks autovacuum from cleaning up — a slow-motion outage on top of the connection-count one.

Confirm. On PostgreSQL, find idle-in-transaction backends and how long they have been stuck:

-- PostgreSQL: idle-in-transaction backends, oldest first — these are the dangerous ones
SELECT pid, usename, application_name, state,
       now() - xact_start AS txn_age, now() - state_change AS idle_age,
       left(query, 60) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start;

On MySQL, long-lived Sleep connections:

-- MySQL: connections asleep for a long time — candidates for a lower wait_timeout
SELECT ID, USER, HOST, TIME AS idle_secs, STATE
FROM information_schema.PROCESSLIST
WHERE COMMAND = 'Sleep' AND TIME > 600
ORDER BY TIME DESC;

Fix. Two levers, server-side and app-side. Server-side, lower the idle timeouts so the engine reclaims slots automatically. On PostgreSQL set idle_in_transaction_session_timeout (kills a backend stuck in an open transaction) and consider idle_session_timeout:

# PostgreSQL: auto-terminate backends idle inside a transaction for > 60s (60000 ms)
az postgres flexible-server parameter set \
  --resource-group rg-shop-prod --server-name pg-shop-prod \
  --name idle_in_transaction_session_timeout --value 60000

On MySQL lower wait_timeout/interactive_timeout so abandoned Sleep connections close:

# MySQL: close non-interactive connections idle for > 300s (helps reclaim leaked Sleep slots)
az mysql flexible-server parameter set \
  --resource-group rg-shop-prod --server-name mysql-shop-prod \
  --name wait_timeout --value 300

App-side is the durable fix: keep transactions short — never hold one open across a third-party network call — and make sure the pool’s idle timeout returns connections promptly. Lowering server timeouts reclaims slots, but a well-behaved app never leaves a transaction idle in the first place.

The idle-state cheat sheet:

State you see Engine Means Danger Lever
Sleep MySQL Open, no query running Holds a slot wait_timeout / pool idle timeout
idle PostgreSQL Open, between queries Holds a slot idle_session_timeout / pool idle timeout
idle in transaction PostgreSQL Open transaction, no commit Holds slot + locks + blocks vacuum idle_in_transaction_session_timeout; fix app
active both Actually running a query Real work (not a leak) Capacity / query tuning, not timeouts

Cause 4 — High fan-out with no pooler (the serverless multiplier)

The storm that ambushes serverless and microservice architectures. Each front-end instance opens its own pool. A pool of 20 is harmless for one instance — but Azure Functions on Consumption or Flex can scale to hundreds of instances, and 200 instances × a 20-connection pool is 4,000 connections at a server whose ceiling might be a few hundred. The database falls over not because any client misbehaved but because the platform multiplied a reasonable per-instance pool by an unreasonable instance count.

Confirm. Count the distinct clients holding connections. On PostgreSQL, group by client address / application:

-- PostgreSQL: how many distinct front-end instances are connected, and how many each holds
SELECT client_addr, count(*) AS conns
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY client_addr
ORDER BY conns DESC;

If you see dozens of distinct client addresses each holding a handful of connections and the sum is near the ceiling, this is fan-out, not a leak.

Fix — PostgreSQL: turn on the built-in PgBouncer. This is the headline advantage of PostgreSQL Flexible Server for this exact problem: a transaction-mode pooler ships in the box, no sidecar to run or patch. Enable it and point the application at port 6432 instead of 5432. Now hundreds of client connections multiplex onto a small set of real backends:

# Enable the built-in PgBouncer and put it in transaction mode
az postgres flexible-server parameter set -g rg-shop-prod -s pg-shop-prod \
  --name pgbouncer.enabled --value true
az postgres flexible-server parameter set -g rg-shop-prod -s pg-shop-prod \
  --name pgbouncer.pool_mode --value transaction
az postgres flexible-server parameter set -g rg-shop-prod -s pg-shop-prod \
  --name pgbouncer.default_pool_size --value 50
# Application now connects on 6432 (PgBouncer), not 5432 (engine)
psql "host=pg-shop-prod.postgres.database.azure.com port=6432 dbname=appdb user=appuser sslmode=require"
// Bicep: PgBouncer is configured via server parameters (configurations)
resource pgbouncerEnabled 'Microsoft.DBforPostgreSQL/flexibleServers/configurations@2023-12-01-preview' = {
  name: '${pg.name}/pgbouncer.enabled'
  properties: { value: 'true', source: 'user-override' }
}
resource pgbouncerMode 'Microsoft.DBforPostgreSQL/flexibleServers/configurations@2023-12-01-preview' = {
  name: '${pg.name}/pgbouncer.pool_mode'
  properties: { value: 'transaction', source: 'user-override' }
}

The PgBouncer parameters you actually tune:

Parameter What it does Sensible default When to change Gotcha
pgbouncer.enabled Turns the pooler on false Always, for serverless / high fan-out App must connect on 6432
pgbouncer.pool_mode session / transaction / statement transaction Keep transaction for the multiplexing win session defeats the purpose
pgbouncer.default_pool_size Server-side backends per user/db pair 50 Size to backends the SKU affords Too high re-creates the storm
pgbouncer.min_pool_size Warm backends kept open 0 Raise to cut cold-connect latency Holds backends even when idle
pgbouncer.max_client_conn Client connections PgBouncer accepts high Cap to protect memory Hitting it rejects clients
pgbouncer.query_wait_timeout Max wait for a free backend 120 s Lower to fail fast under saturation Too low errors legit queries

Fix — MySQL: there is no in-box pooler, so pool small and front it. Your options, in order: (1) cap the per-instance driver pool aggressively so instances × pool stays under the ceiling; (2) for genuine high fan-out, run an external proxy — a ProxySQL instance — to multiplex connections the way PgBouncer does for Postgres; (3) reduce the instance count or batch work so fewer pools exist. There is no az flag for a built-in MySQL pooler because the feature does not exist — do not go looking for one.

The decision is stark and worth a table:

Situation PostgreSQL Flexible Server MySQL Flexible Server
One app, modest concurrency Driver pool is enough Driver pool is enough
High fan-out (serverless / many instances) Enable built-in PgBouncer (6432) Cap pools small + external ProxySQL
Need transaction pooling without app changes PgBouncer transaction mode ProxySQL (you operate it)
Want zero pooler to run/patch PgBouncer is managed in-box No managed option — you run the proxy

Cause 5 — Connection pools split across read replicas

You added one or more read replicas to offload reporting or read traffic. Each replica is a separate server with its own max_connections, and — crucially — your application now holds pools to each endpoint. If you sized each pool to the primary’s ceiling, the aggregate across primary + replicas can exceed what you intended, and a misrouted write or an analytics job hammering a replica can exhaust that replica independently while the primary looks fine.

Confirm. Check each endpoint separately; a replica can be exhausted while the primary is healthy:

-- Run on EACH endpoint (primary and each replica) — PostgreSQL
SELECT (SELECT setting::int FROM pg_settings WHERE name='max_connections') AS max_conn,
       count(*) AS current
FROM pg_stat_activity;
# List replicas so you know every endpoint to check (PostgreSQL example)
az postgres flexible-server replica list \
  --resource-group rg-shop-prod --server-name pg-shop-prod -o table

Fix. Pool per role, deliberately: a small write pool to the primary, a separate read pool to the replica set, each sized so the sum hitting any one server stays under that server’s ceiling. Route reads to replicas explicitly in code rather than letting an ORM open ad-hoc connections. And remember replicas are asynchronous — they can lag — so do not route read-after-write paths to them; that is a correctness bug, not just a connection one.

Topology Connection sizing rule Failure if ignored
Primary only Total app pools < primary ceiling Standard storm
Primary + 1 read replica Write pool + read pool, each < its server’s ceiling Replica exhausted by analytics while primary idle
Primary + N replicas Sum to any one replica < that replica’s ceiling One hot replica storms while others sit empty

Cause 6 — It is genuinely real load

Sometimes there is no bug. Every connection is active, running a real query, and you simply have more concurrent work than the current SKU can serve. This is the one case where the on-call instinct to scale is correct — after you have ruled out the other five, because they are far more common.

Confirm. The signature is the inverse of a leak: most connections are active, not idle:

-- PostgreSQL: ratio of active to idle — mostly 'active' near the ceiling = real load
SELECT state, count(*) FROM pg_stat_activity
WHERE backend_type = 'client backend' GROUP BY state;
-- MySQL: connections actually executing (not Sleep) — high and sustained = real load
SELECT COMMAND, COUNT(*) FROM information_schema.PROCESSLIST
WHERE COMMAND <> 'Sleep' GROUP BY COMMAND;

If active connections track CPU and throughput upward together, the database is doing genuine work at its limit. Fix. Scale the SKU up (more memory and vCores), add read replicas to offload reads, and cache hot reads in Azure Cache for Redis so fewer requests reach the database at all. Pooling still helps — it keeps the connection count efficient — but it cannot manufacture capacity that the workload genuinely needs.

The error-code and message reference

Mid-incident you scan this first: every connection-related error you realistically see from either engine, what it means on Flexible Server, the likely cause, how to confirm, and the first fix. The traps are telling a pure connection-count error apart from an authentication/firewall error that looks similar, and recognising the PgBouncer-specific errors that only appear once you turn pooling on.

Error / message Engine Meaning Likely cause How to confirm First fix
ERROR 1040 (HY000): Too many connections MySQL Ceiling reached Pool too big / fan-out / leak Threads_connected vs max_connections Pool smaller; then scale SKU
ERROR 1203: User already has more than 'max_user_connections' MySQL Per-user cap hit A max_user_connections set below max_connections SHOW VARIABLES LIKE 'max_user_connections' Raise per-user cap or pool down
FATAL: sorry, too many clients already PostgreSQL Ceiling reached Pool too big / fan-out / leak count(*) in pg_stat_activity vs max_connections Enable PgBouncer; pool smaller
FATAL: remaining connection slots are reserved for ... superusers PostgreSQL Only reserved slots left App used all non-reserved slots Active count near ceiling Free idle slots; pool down; scale
remaining connection slots are reserved for roles with the SUPERUSER PostgreSQL Variant of the above Same Same Same
prepared statement "S_1" does not exist PostgreSQL (via PgBouncer) Server-side prepared stmt lost between transactions Transaction pooling + server-side prepares Errors appear only after PgBouncer enabled Disable server-side prepares in driver
server closed the connection unexpectedly PostgreSQL Backend died / idle-timeout killed it OOM, idle-timeout, failover Server logs; metrics; idle_*_timeout Reconnect logic; investigate OOM/timeout
Lost connection to MySQL server during query / error 2013 MySQL Connection dropped mid-query Timeout, wait_timeout, restart, network Server logs; wait_timeout value Retry; raise timeout if legitimately long
MySQL server has gone away / error 2006 MySQL Connection idle-closed or packet too big wait_timeout reaped it; or max_allowed_packet wait_timeout; query size Reconnect; tune timeout / packet size
ERROR 1226: User has exceeded the 'max_connections_per_hour' resource MySQL Per-user hourly resource cap A user-level resource limit set SHOW GRANTS / resource options Raise/clear the per-user resource limit
Connection times out / SocketException from App Service both Outbound failure before the DB SNAT port exhaustion at App Service SNAT detector; NAT Gateway absent NAT Gateway / Private Endpoint; reuse conns

Three reading notes that save the most time:

Distinction The trap How to tell them apart
Connection-count error vs auth/firewall error Both look like “can’t connect” A count error fires only under load and clears as connections free; an auth/firewall error fails 100% regardless of load (see Troubleshooting Azure SQL: Login Failed, Firewall and Entra Auth for the auth side)
Engine ceiling vs PgBouncer ceiling After enabling PgBouncer you can hit its max_client_conn FATAL: too many clients from the engine vs PgBouncer rejecting at the pooler — check SHOW POOLS on 6432
DB connection failure vs App Service SNAT exhaustion The DB is blamed for an App Service egress problem If the SNAT detector shows failed SNAT connections and the DB’s own active-connections metric is low, the failure is outbound, not the database

Reading who holds connections: the live forensics

When the storm is active, two queries tell you everything — keep them in a snippet file. On PostgreSQL, pg_stat_activity is your single source of truth (state, age, query, client):

-- The one query to run during a PostgreSQL connection storm
SELECT state,
       count(*)                              AS conns,
       count(*) FILTER (WHERE state='idle in transaction') AS idle_in_txn,
       max(now() - state_change)             AS longest_idle,
       array_agg(DISTINCT application_name)  AS apps
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY state
ORDER BY conns DESC;

If you must shed load right now to recover, you can terminate the worst offenders (use surgically — this kills the backend and its transaction):

-- Last resort: terminate backends idle-in-transaction for > 5 minutes
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle in transaction' AND (now() - state_change) > interval '5 minutes';

On MySQL, SHOW PROCESSLIST / information_schema.PROCESSLIST is the equivalent, and KILL is the manual recovery:

-- MySQL: the storm-time view, plus the kill template
SELECT COMMAND, COUNT(*) n, AVG(TIME) avg_secs, MAX(TIME) max_secs
FROM information_schema.PROCESSLIST GROUP BY COMMAND ORDER BY n DESC;
-- KILL <ID>;  -- terminate a specific runaway connection by its process ID

When PgBouncer is enabled, there is a third place to look — the special pgbouncer admin database on port 6432 — which shows the multiplexing in action (many client connections, few server connections):

# Connect to the PgBouncer admin DB and read the pools
psql "host=pg-shop-prod.postgres.database.azure.com port=6432 dbname=pgbouncer user=pgadmin sslmode=require" \
  -c "SHOW POOLS;"
# cl_active = client conns busy, sv_active = server backends in use — sv should be << cl

What each forensic source tells you, at a glance:

Source Engine Shows Best for
pg_stat_activity PostgreSQL Per-backend state, age, query, client Finding leaks, idle-in-txn, fan-out
information_schema.PROCESSLIST MySQL Per-thread command, state, time, user Finding Sleep leaks, runaway queries
SHOW POOLS (port 6432) PostgreSQL + PgBouncer Client vs server connection counts Proving the pooler is multiplexing
active_connections metric both Time-series of open connections Alerting and trend, not per-connection detail

Architecture at a glance

Follow the connection from the application to the engine and the storm becomes visual. Requests arrive at a front-end scale unit — App Service, or a serverless plane of Azure Functions / Container Apps that the platform multiplies to many instances under load. Each instance holds its own driver pool, so the connection count the database sees is pool size × instance count, not pool size. That product is the storm’s fuel. On the way out, App Service maps each outbound TCP connection through a finite SNAT port budget; if the app opens connections wastefully, it can exhaust source ports and fail before the packet ever reaches the database — a failure that masquerades as a database problem (badge 1).

At the database tier the two engines diverge. On PostgreSQL Flexible Server, you route through the built-in PgBouncer on port 6432 (badge 2): the pooler accepts hundreds of client connections and multiplexes them onto a small set of backends, so the engine on 5432 only ever sees default_pool_size connections. On MySQL Flexible Server there is no in-box pooler, so the driver pools (or an external ProxySQL you operate) must keep the count down before it reaches port 3306. Either way the connections land on the engine, where max_connections — derived from the SKU’s memory and partly reserved for the platform’s monitoring/admin slots — is the wall (badge 3). Add read replicas (badge 4) and the app holds pools to multiple endpoints, each with its own ceiling, multiplying the totals again. The diagram maps each numbered failure onto the hop where it bites, and the legend narrates every number as symptom · how to confirm · fix.

Left-to-right architecture of a connection storm against Azure Database for MySQL and PostgreSQL Flexible Server: a front-end scale unit of App Service and Functions/Container Apps instances each holding a driver pool sends connections through an App Service SNAT egress hop, into the database tier where PostgreSQL routes through the built-in PgBouncer on port 6432 while MySQL connects directly on 3306, landing on the engine where max_connections derived from SKU memory is the ceiling, with read replicas each carrying their own max_connections; numbered badges mark SNAT exhaustion, missing pooler, max_connections wall, and replica pool-splitting, and the legend gives symptom, confirm command, and fix for each.

Real-world scenario

Saffron Cart, a mid-size Indian e-commerce store, moved its order pipeline to Azure Functions (Flex Consumption) reading and writing PostgreSQL Flexible Server — a General Purpose D2ds_v4 (2 vCores, 8 GB) in Central India. In normal traffic it was flawless: the functions held a small Npgsql pool (Maximum Pool Size = 20), the database sat at single-digit CPU, and the team congratulated themselves on a clean serverless design.

The first festival flash sale broke it at 13:02. Order-placement requests began failing with FATAL: sorry, too many clients already — at 19% CPU. The on-call engineer, reasoning that a sale means “more load,” scaled the SKU to D4ds_v4, doubling compute. The storm paused for eleven minutes (the bigger SKU’s higher memory-derived max_connections absorbed it) and returned, identical, now at 11% CPU. Scaling bought a brief reprieve and a higher bill, and fixed nothing.

The real diagnosis took one query. SELECT client_addr, count(*) FROM pg_stat_activity GROUP BY client_addr showed forty-three distinct client addresses, each holding 18–20 connections — roughly 820 connections, almost all in state idle, against a ceiling in the low hundreds. The platform had scaled Functions to forty-three instances, and each had faithfully opened its own twenty-connection pool. No single pool was wrong; the product was. Textbook Cause 4 — high fan-out with no pooler — treated by a bigger SKU as Cause 6.

The fix was three parameters and a port change. They enabled the built-in PgBouncer (pgbouncer.enabled=true, pool_mode=transaction, default_pool_size=50) and repointed the connection string from 5432 to 6432. Immediately SHOW POOLS told the story: ~800 client connections multiplexed onto ~50 server backends, and the engine never saw more than the pool size however far Functions scaled. They reverted the SKU to D2ds_v4 — never the problem — and alerted on active_connections at 70% of the ceiling so the next storm would page before refusing a customer.

The follow-up surfaced one transaction-pooling gotcha: a reporting query used a server-side prepared statement and threw prepared statement does not exist under PgBouncer. The fix was disabling server-side prepares (Max Auto Prepare=0) for that workload only. Monthly cost went down, the next sale ran clean, and the lesson on the wall read: “A connection storm is an application-fan-out problem wearing a database costume. Count the clients before you touch the SKU.”

Advantages and disadvantages

The two engines’ approaches to this problem trade off cleanly, and choosing well at design time saves you the 13:02 incident:

Approach Advantages Disadvantages
PostgreSQL Flexible Server + built-in PgBouncer Managed pooler in the box — nothing to run/patch; transaction pooling gives huge multiplexing; survives failover on the same FQDN:6432 Transaction pooling breaks session state (prepared statements, LISTEN/NOTIFY, temp tables); the pool is local to the primary, not a tier you scale independently
MySQL Flexible Server + driver pooling only Simple; no extra hop; fine for one app at modest concurrency No in-box pooler; high fan-out forces you to operate an external ProxySQL; easy to storm if pools aren’t capped
Scaling the SKU up The right fix for a genuinely small ceiling or genuine load; immediate Hides leaks and fan-out; recurring cost; the storm returns at the next peak if the cause was application-side
Driver pooling without a server pooler Zero extra infrastructure; lowest latency per query Breaks under fan-out — connections = pool × instances; a pool correct for 1 instance storms at 40

When each matters: reach for PgBouncer transaction mode the moment a PostgreSQL database sits behind anything serverless or many-instance — the highest-leverage fix, and free. Accept its session-state limit by keeping the app stateless at the connection level. On MySQL, lean on small, correctly-sized driver pools first, and stand up an external proxy only when fan-out genuinely demands it — that proxy is real operational weight you now own. Reserve scaling the SKU for the two causes it actually fixes: a ceiling genuinely too low for a well-pooled app, and genuine sustained load.

Hands-on lab

This lab reproduces a connection storm on a cheap PostgreSQL Flexible Server, observes the ceiling, then fixes it with the built-in PgBouncer — end to end, teardown included. A Burstable SKU keeps it inexpensive; stop the server when you pause to avoid compute charges.

1. Create a small server (Burstable, public access for the lab).

RG=rg-poollab; LOC=centralindia; PG=pg-poollab-$RANDOM
az group create -n $RG -l $LOC
az postgres flexible-server create \
  --resource-group $RG --name $PG --location $LOC \
  --tier Burstable --sku-name Standard_B1ms \
  --storage-size 32 --version 16 \
  --admin-user pgadmin --admin-password 'Lab-Passw0rd!2026' \
  --public-access 0.0.0.0   # adds a firewall rule for Azure services; tighten for real use

2. Read the effective ceiling — this is the wall you will hit.

FQDN=$(az postgres flexible-server show -g $RG -n $PG --query fullyQualifiedDomainName -o tsv)
psql "host=$FQDN port=5432 dbname=postgres user=pgadmin sslmode=require" -c "SHOW max_connections;"
# Note the number — it is small on B1ms because max_connections scales with memory.

3. Storm it. Open more connections than the ceiling with a tiny loop (each psql stays open via a sleep). Expect FATAL: sorry, too many clients already once you pass the ceiling:

# Open N background psql sessions that each hold a connection idle for 60s
for i in $(seq 1 80); do
  psql "host=$FQDN port=5432 dbname=postgres user=pgadmin sslmode=require" \
    -c "SELECT pg_sleep(60);" >/dev/null 2>&1 &
done
# In another shell, watch the count climb toward the ceiling and then refuse:
psql "host=$FQDN port=5432 dbname=postgres user=pgadmin sslmode=require" \
  -c "SELECT count(*) AS open_conns FROM pg_stat_activity;"

4. See who is holding slots (the forensic query you would run in production):

psql "host=$FQDN port=5432 dbname=postgres user=pgadmin sslmode=require" -c "
SELECT state, count(*) FROM pg_stat_activity
WHERE backend_type='client backend' GROUP BY state;"
# A pile of 'active' (running pg_sleep) — in a real leak these would be 'idle'.

5. Fix it with the built-in PgBouncer. Enable the pooler, set transaction mode, then connect on 6432:

az postgres flexible-server parameter set -g $RG -s $PG --name pgbouncer.enabled --value true
az postgres flexible-server parameter set -g $RG -s $PG --name pgbouncer.pool_mode --value transaction
az postgres flexible-server parameter set -g $RG -s $PG --name pgbouncer.default_pool_size --value 20
# Connect through the pooler (6432). Many clients now multiplex onto ~20 backends.
psql "host=$FQDN port=6432 dbname=postgres user=pgadmin sslmode=require" -c "SHOW max_connections;"

6. Prove the multiplexing via the PgBouncer admin database:

psql "host=$FQDN port=6432 dbname=pgbouncer user=pgadmin sslmode=require" -c "SHOW POOLS;"
# cl_active (clients) >> sv_active (server backends) — the pooler is doing its job.

7. Teardown. Delete the resource group so nothing lingers on the bill:

az group delete --name $RG --yes --no-wait

What you should have seen: a small SKU refusing connections at a low ceiling, the ceiling confirmed with one query, and the same workload sailing through once it multiplexed via PgBouncer on 6432 — without changing the SKU at all.

Common mistakes & troubleshooting

The playbook, expanded. Find your symptom, confirm with the exact command, apply the fix. This table spans the basic mistakes (oversized pool, wrong port) and the advanced ones (idle-in-transaction, replica splitting, SNAT exhaustion):

# Symptom Root cause Confirm (exact command / portal path) Fix
1 Too many connections (MySQL) at low CPU Pool × instances exceeds ceiling SHOW STATUS LIKE 'Threads_connected' vs max_connections Cap driver pool; for fan-out add ProxySQL
2 too many clients already (PG) at low CPU Same, no pooler count(*) in pg_stat_activity vs max_connections Enable PgBouncer on 6432; size pool
3 Connections climb and never fall Leaked connections (unclosed handles) pg_stat_activity shows growing idle under one app Wrap in using/finally; close on every path
4 Many idle in transaction (PG) Open transaction across a slow call / bug Idle-in-txn query (oldest first) Set idle_in_transaction_session_timeout; shorten txns
5 Sea of Sleep connections (MySQL) Idle pooled connections never reaped PROCESSLIST where COMMAND='Sleep' Lower wait_timeout; tune pool idle timeout
6 Storm only under load, fine at rest Serverless fan-out (instances × pool) GROUP BY client_addr count of distinct instances PgBouncer (PG) / small pools + proxy (MySQL)
7 Replica refuses while primary is fine Pools split; replica exhausted independently Run the ceiling query on the replica endpoint Pool per role; cap each pool to its server
8 Scaling SKU “fixes” it for an hour, then returns Treating fan-out/leak as capacity Connections mostly idle near ceiling Pool down first; SKU is not the cause
9 prepared statement does not exist after enabling PgBouncer Transaction pooling + server-side prepares Error appears only post-PgBouncer Disable server-side prepares (Max Auto Prepare=0, JDBC prepareThreshold=0)
10 remaining connection slots are reserved for superusers (PG) App used all non-reserved slots Active count = ceiling − reserved Free idle slots; size pool below ceiling − reserved
11 DB times out from App Service, DB metric is low SNAT port exhaustion (outbound), not DB Diagnose and solve → SNAT Port Exhaustion; DB active_connections low NAT Gateway / Private Endpoint; reuse connections
12 MySQL server has gone away (2006) Idle connection reaped by wait_timeout; or large packet Compare idle time to wait_timeout; check query size Reconnect logic; raise wait_timeout/max_allowed_packet if legit
13 User has exceeded the max_user_connections (1203/1226) Per-user cap below max_connections SHOW VARIABLES LIKE 'max_user_connections'; SHOW GRANTS Raise/clear the per-user resource limit
14 After raising max_connections, random query failures Ceiling pushed past what RAM supports → OOM Memory metric near 100%; backends killed Lower max_connections; scale memory instead; pool
15 Connections fine, but app slow under load All backends busy — genuine load pg_stat_activity mostly active; CPU/throughput high Scale SKU; add read replicas; cache hot reads

Per-symptom detail on the three that fool people most

Idle in transaction (row 4) is the silent killer. It holds not just a slot but locks, and it blocks autovacuum — so on top of the connection-count incident you get table bloat and lock waits. The cause is almost always a transaction left open across a network call (a payment gateway, an external API) or a paused debugger. idle_in_transaction_session_timeout is a seatbelt, not a fix: the real repair is to never hold a transaction open across anything slow.

SNAT exhaustion (row 11) is a database problem that is not a database problem. When App Service makes many outbound connections without reuse, it can exhaust its finite SNAT port budget (about 128 pre-allocated per instance). New connections fail, and because the failure reads “can’t connect to the database,” everyone stares at the database — whose active_connections metric is sitting low and innocent. The tell is exactly that contradiction. Confirm in Diagnose and solve problems → SNAT Port Exhaustion, then fix on the network side: a NAT Gateway gives a vastly larger source-port pool, and a Private Endpoint keeps traffic off SNAT entirely.

Scaling that “works” then fails (row 8) is the most expensive mistake. A bigger SKU raises the memory-derived max_connections, so it absorbs an oversized pool or fan-out long enough to look like a fix and teach the wrong lesson. The diagnostic that cuts through it: if the connections near the ceiling are mostly idle, scaling is treating an application bug as a capacity problem. Pool down first; scale only when the connections at the ceiling are genuinely active.

Best practices

Security notes

Connection management and security overlap more than people expect. Pooling concentrates credentials and traffic, so get the surrounding posture right:

Cost & sizing

The connection budget is the cheapest resource to get wrong expensively. The bill is driven by the compute SKU (vCores + memory), storage (size + IOPS), HA (a standby doubles compute), backups beyond your provisioned storage, and any read replicas (each a full compute charge). max_connections itself is free — it rides on the memory you already pay for — which is exactly why “scale the SKU to get more connections” is a tax you pay forever for a problem pooling solves once.

What drives the bill Lever Rough effect Note
Compute SKU (vCores + RAM) Tier/SKU The dominant cost; also sets max_connections Scale for real load, not to hide a pool bug
Read replicas Add/remove A full compute charge per replica Each carries its own connection ceiling
HA (zone-redundant) Enable/disable ~Doubles compute (hot standby) Worth it for prod; not for the connection problem
Storage + IOPS Size / tier Grows but never shrinks Auto-grow on; unrelated to connections
PgBouncer Free (in-box) No extra charge Solves the storm at zero infra cost
External ProxySQL (MySQL) You operate it A VM/container you pay + run Only when fan-out genuinely demands it

Sizing guidance by need:

Need Tier / move Why
Dev / learning / one low-traffic app Burstable B1ms/B2s Cheapest real server; stop it overnight to pay storage only
Production, modest concurrency, well-pooled General Purpose D2ds_v4+ Enough memory-derived ceiling; pool keeps it efficient
High fan-out (serverless / microservices) GP SKU + PgBouncer (PG) The pooler — not a bigger SKU — is the fix
Genuinely connection/memory-heavy Business Critical (Memory Optimized) Highest ceilings; still pool correctly

The single biggest dev-cost lever remains stop/start: a stopped Flexible Server bills only for storage. For the connection problem specifically, the lever is the realisation that pooling is free and scaling is not — fixing a fan-out storm with PgBouncer can let you scale a SKU back down, as Saffron Cart did.

Rough INR/USD reference (Central India, list, varies): a Burstable B1ms runs ~₹1,000–1,400 / month (~$12–17) of compute; a General Purpose D2ds_v4 ~₹9,000–11,000 / month (~$110–135); HA roughly doubles compute; a read replica adds a full compute charge. PgBouncer adds ₹0.

Interview & exam questions

These map to AZ-204 (developing solutions for Azure, including connection resilience and managed databases) and DP-300 (administering relational databases on Azure).

  1. Why does max_connections not scale with CPU? Each connection costs memory (a forked backend on PostgreSQL, a thread with buffers on MySQL), so the platform derives the ceiling from the SKU’s memory, reserving RAM per slot. A box refuses connections at 20% CPU because the limiting resource is memory-backed slots, not compute.

  2. A serverless app gets “too many clients” at 15% CPU. What is happening, and the fix? High fan-out: each of N instances holds its own pool, so connections = pool × N. The fix is a transaction-mode pooler — built-in PgBouncer on PostgreSQL (6432) — not a bigger SKU, which only hides the multiplication.

  3. What does idle in transaction cost beyond a slot? It holds the transaction’s locks and blocks autovacuum, causing bloat and lock waits on top of the connection-count problem. Guard with idle_in_transaction_session_timeout; fix by keeping transactions short.

  4. MySQL has no built-in pooler. How do you handle high fan-out? Cap per-instance driver pools small so pool × instances stays under the ceiling, and for genuine fan-out front the database with an external proxy such as ProxySQL. There is no managed in-box pooler to enable.

  5. What is the trade-off of PgBouncer transaction mode? It multiplexes many clients onto few backends but returns the server connection at the end of each transaction, breaking anything assuming a stable session — server-side prepared statements, LISTEN/NOTIFY, temp tables. Disable server-side prepares in the driver to coexist.

  6. How do you confirm a failure is the database vs App Service SNAT exhaustion? Compare the SNAT detector (failed SNAT connections) against the database’s active_connections metric. SNAT failing while the DB count is low means an outbound source-port problem — fixed with NAT Gateway or Private Endpoint, not the database.

  7. Why can raising max_connections by hand make things worse? It is bounded by the SKU’s memory, and every slot reserves RAM your queries need. Push it past what memory supports and the engine OOM-kills backends, turning “connection refused” into random query failures.

  8. A read replica refuses connections while the primary is healthy. Why? Each replica is a separate server with its own max_connections, and the app holds a separate pool to it; an analytics job can exhaust it independently. Pool per role and size each pool to its own server.

  9. What reserved capacity should you account for on PostgreSQL? superuser_reserved_connections plus the platform’s monitoring/management connections are carved out of max_connections, so usable app slots are fewer than the printed number. Size total app pools below ceiling − reserved to keep an admin login available.

  10. Which storm causes does scaling the SKU actually fix? Only two: a ceiling genuinely too small for a well-pooled app, and genuine sustained load (connections mostly active). Oversized pools, leaks, idle-in-transaction, missing pooler, and replica-splitting are bugs a bigger SKU only postpones.

  11. How do you size a driver pool to start? Begin near cores × 2 (HikariCP’s guidance generalises well), cap the maximum explicitly, and verify pool max × instances stays under max_connections − reserved. Then measure under load and adjust.

  12. Where do you look, live, to see who holds connections? pg_stat_activity on PostgreSQL and information_schema.PROCESSLIST / SHOW PROCESSLIST on MySQL. With PgBouncer enabled, SHOW POOLS on the pgbouncer admin database (6432) proves the multiplexing.

Quick check

  1. On Flexible Server, which resource is max_connections derived from — CPU, memory, or storage?
  2. Which port does the built-in PgBouncer listen on, and which engine has it?
  3. You see hundreds of idle connections near the ceiling. Is a bigger SKU the right first move? Why or why not?
  4. What does PostgreSQL’s idle in transaction block beyond holding a connection slot?
  5. The app cannot reach the database, but the database’s active_connections metric is low. What should you suspect first?

Answers

  1. Memory. Each connection reserves RAM (a forked backend on PostgreSQL, a thread + buffers on MySQL), so the ceiling scales with the SKU’s memory, not CPU or storage.
  2. Port 6432, on PostgreSQL Flexible Server (the engine itself is on 5432). MySQL Flexible Server has no built-in pooler.
  3. No. Mostly-idle connections near the ceiling signal an oversized/leaked pool or fan-out — an application problem. Pool down first; a bigger SKU only raises the ceiling enough to hide the bug for a while.
  4. It holds any locks the transaction took and blocks autovacuum, causing table bloat and lock waits on top of the connection-count problem.
  5. App Service SNAT port exhaustion (outbound source-port exhaustion), not the database. Confirm with the SNAT detector; fix with a NAT Gateway or a Private Endpoint to the database.

Glossary

Next steps

AzureMySQLPostgreSQLFlexible ServerConnection PoolingPgBouncerTroubleshootingmax_connections
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading