Every MySQL fleet I have worked on eventually hits the same ritual. An application throws "Too many connections", someone doubles max_connections, the error goes away, and the setting stays doubled forever. Six months later it happens again, someone doubles it again, and now a mid-sized instance is configured to accept 4,000 connections it could never usefully serve.
The folklore says max_connections is a capacity dial. It is not. It is a ceiling on how much concurrent demand you allow to pile up inside the server. The real capacity question is how many threads can be running at once before the machine stops making progress, and that number is far smaller than most max_connections settings imply.
This is a field guide to sizing connections for MySQL 8.x honestly: what a connection actually costs, which status counters matter, how to do the pool math on the application side, and what the too-many-connections incident looks like when it finally arrives.
What a connection actually costs
MySQL uses a thread-per-connection model in the standard builds most teams run. Every client connection gets a dedicated operating system thread inside mysqld. That has two cost profiles, and conflating them is where the folklore starts.
The fixed cost per connection is modest: a thread stack (thread_stack, roughly 1 MB by default on 64-bit builds), connection and result buffers that start at net_buffer_length and can grow toward max_allowed_packet, and assorted session state. An idle connection is cheap enough that thousands of them do not sink a server by themselves.
The variable cost is where servers die. Per-session work buffers - sort_buffer_size, join_buffer_size, read_buffer_size, read_rnd_buffer_size, and in-memory temporary tables up to tmp_table_size - are allocated per operation as queries execute, and some of them can be allocated multiple times within a single query. A connection running a nasty query can transiently use tens or hundreds of megabytes. Multiply that by real concurrency, not by connection count, and you have the honest memory model. The old "max_connections times sum of session buffers" worst-case formula overstates idle cost and understates what a burst of active queries does. If you have already committed most of RAM to the buffer pool - see the InnoDB buffer pool sizing field guide - the headroom for a concurrency burst is thinner than you think.
There is also a scheduling cost. Thousands of runnable threads mean context-switch pressure and mutex contention inside InnoDB. The server does not degrade linearly as running threads climb past the core count; it degrades in a hurry.
Threads_running is the signal, Threads_connected is the noise
The counters that matter live in global status, and most dashboards graph the wrong one.
SHOW GLOBAL STATUS WHERE Variable_name IN
('Threads_connected', 'Threads_running', 'Threads_created',
'Threads_cached', 'Connections', 'Max_used_connections',
'Aborted_connects', 'Aborted_clients');
Threads_connected is how many clients are attached. Threads_running is how many are actually executing something right now, not sleeping. On a healthy OLTP system, Threads_connected can sit at 800 while Threads_running hovers between 2 and 10. Nobody should be alarmed by that gap; it is exactly what connection pools plus fast queries look like.
Threads_running is the number to watch and alert on. As a rough operating posture: sustained Threads_running near or below the CPU core count is comfortable; a few multiples of core count is a warning; ten times core count means work is arriving faster than it completes and latency is compounding. I do not treat any specific multiple as a law - it depends on how much of the running work is waiting on I/O versus burning CPU - but a step change in Threads_running is the earliest honest sign of trouble, usually minutes before the connection counters notice.
The causality is the part the folklore gets backwards. Connections do not pile up because max_connections is too low. They pile up because queries got slow - a lock wait, a plan flip, an I/O stall - so each connection is held longer, so the pool opens more, so Threads_connected climbs toward the ceiling. The connection spike is the symptom. Threads_running, lock waits, and slow query evidence are the cause.
thread_cache_size and the cost of churn
Creating an OS thread per connection is not free, so MySQL keeps a cache of threads to reuse. When a client disconnects, its thread can be parked in the cache; when a new client connects, a cached thread is reused instead of created. The relevant counters are Threads_created (cumulative thread creations) against Connections (cumulative connection attempts).
SELECT tc.VARIABLE_VALUE AS threads_created,
c.VARIABLE_VALUE AS total_connections,
ROUND(tc.VARIABLE_VALUE / c.VARIABLE_VALUE * 100, 2)
AS cache_miss_pct
FROM performance_schema.global_status tc
JOIN performance_schema.global_status c
ON c.VARIABLE_NAME = 'Connections'
WHERE tc.VARIABLE_NAME = 'Threads_created';
In MySQL 8.x the default thread_cache_size is auto-sized (8 plus max_connections divided by 100, capped at 100), which is fine for most fleets. If the miss percentage is high and climbing while your application supposedly uses pooling, the interesting finding is usually not the cache size - it is that something is churning connections. Lambda-style workloads, health checks that open a fresh session per probe, or a pool configured with an aggressive idle timeout will all show up here. Fix the churn before tuning the cache.
Since these are counters since restart, look at the rate of Threads_created over time rather than the lifetime ratio on a server that has been up for a year.
The actual sizing math
Work from the demand side, not the ceiling side.
Start with the application pools, because in a pooled architecture total connections are simply pool size times pool instances. Twenty service replicas with a pool max of 30 is 600 potential connections from one service alone. Add every service, every cron host, every dashboard tool, and the replication and monitoring overhead, and that sum - not folklore - is the demand your ceiling has to accommodate.
Then size each pool against the database, not against the application's fear of running out. A reasonable starting point for an OLTP service is a pool max in the low tens per database, informed by how many queries are actually in flight: if your service does 500 queries per second at 4 ms each, that is about 2 connections' worth of real work (500 times 0.004). A pool of 10 to 20 covers bursts with margin. Pools of 100 per replica mostly guarantee that when the database slows down, it gets hit with a bigger pile-on.
If the demand sum genuinely exceeds what one server should accept, put ProxySQL (or another mid-tier pooler) in front. ProxySQL multiplexes many frontend client connections onto a much smaller set of backend connections, so 3,000 application connections can be served by 100 backend threads doing actual work. The sizing logic on the backend side is the same Threads_running logic: backend connections a bit above the concurrency the server handles well, not above what the applications demand. Note that multiplexing has caveats - session state such as user variables, temporary tables, or LOCK TABLES pins a client to a backend connection and defeats the sharing - so measure the multiplexing ratio rather than assuming it.
Finally set max_connections above the legitimate demand sum with margin for humans and failover, and treat hitting it as an incident signal, not a tuning prompt. If you also run PostgreSQL, the two engines reward opposite instincts here; the comparison in MySQL connections vs PostgreSQL connections covers why MySQL tolerates high idle connection counts far better than Postgres does, and why that tolerance becomes a trap.
The shape of a too-many-connections incident
Error 1040 incidents follow a script. Queries slow down for some upstream reason. Pools expand to compensate. Threads_connected climbs the ramp to max_connections. New connections start failing - including, painfully, the DBA trying to log in to diagnose. Application retries and health-check restarts add a connection storm on top. The database is now busy rejecting connections and serving a pile of slow queries at once.
Prepare for it before it happens. First, MySQL 8.0.14+ has an admin interface: set admin_address (and optionally admin_port, default 33062), and users with SERVICE_CONNECTION_ADMIN can connect on it even when regular connections are exhausted. Also, one extra connection above max_connections is reserved for a user with CONNECTION_ADMIN (or the legacy SUPER) privilege - do not let routine tooling burn it. Second, when you get in, resist the urge to raise max_connections first. Look at Threads_running and the processlist, find the slow or blocked query family that started the ramp, and kill or fix that. Raising the ceiling on a server that cannot finish its current work just deepens the pile-up.
Afterwards, the postmortem question is never "was max_connections too low?" It is "what made connection hold time spike, and why did we not see Threads_running move?"
Monitoring this, and where MonPG is headed
The durable takeaways: graph Threads_running and Threads_connected together and alert on the former; watch Threads_created rate for churn; know your demand sum (pool max times instances, per service); and keep an admin-interface path tested before you need it.
MonPG today is a PostgreSQL monitoring platform - connection state, query evidence, lock chains, and the rest of the incident workflow - and MySQL support is being built now, with connection and thread-state visibility like the above as a core part of the design. It is not shipped yet, and I will not pretend otherwise: see MySQL monitoring (coming soon) for what is planned and to get notified. If your fleet also includes PostgreSQL, you can start there today with the same evidence-first approach.