VillageSQL is a drop-in replacement for MySQL with extensions.
All examples in this guide work on VillageSQL. Install Now →
mysql2, then measures what happens to MySQL’s connection count under concurrent load with each pattern.
What This Guide Tested, and What It Did Not
Everything below ran against a real, local VillageSQL server and a real Node process. The application is a Hono app built with thenodejs template (npm create hono@latest), which runs as an ordinary long-lived Node process during this guide’s testing, not deployed to Cloudflare Workers, AWS Lambda, or Vercel’s edge runtime.
That matters for one specific claim this guide makes and one it does not:
- What was tested for real: a request handler that opens a fresh MySQL connection every time it runs, versus a handler that reuses a connection pool created once at module load. Firing concurrent HTTP requests at each version and watching MySQL’s connection count is a faithful reproduction of what happens inside a single serverless function instance handling concurrent invocations, or a cold-starting instance whose module-level code re-runs.
- What was not tested: the multiplication effect of many separate execution environments cold-starting at once, each running its own copy of this process and opening its own pool. A real burst of traffic on AWS Lambda or Cloudflare Workers can spin up dozens of isolated instances simultaneously, and a pool of 10 connections in each of them adds up fast even with the fix in this guide applied. The “Beyond a Single Instance” section below covers what to do about that, but it was not something this guide’s Node process, which is a single instance by definition, could reproduce.
Set Up the Database
Create a dedicated database and a user scoped to it:Scaffold the Application
This guide uses Hono’snodejs template, which runs on @hono/node-server and does not require a specific cloud platform to develop against:
mysql2 driver, and drizzle-kit:
drizzle-orm and drizzle-kit both publish pre-release tags (beta, rc) under dist-tags alongside latest. Check before installing:
latest for drizzle-orm resolves to 0.45.2 and for drizzle-kit to 0.31.10, both stable releases, not release candidates.
Set the connection string in .env:
Define the Schema and Run the Migration
src/db/schema.ts
drizzle.config.ts
The Anti-Pattern: A Connection Per Request
The most common way this problem shows up is code that looks reasonable in isolation. It opens amysql2 connection, runs a query, and closes the connection, all inside the request handler:
src/index.bad.ts
Demonstrating the Exhaustion
MySQL’smax_connections is a server-wide ceiling, and lowering it on a shared server to force a failure would affect every other connection on that instance. A more targeted way to reproduce a connection ceiling for a single application, and one cloud MySQL providers use for exactly this reason, is MAX_USER_CONNECTIONS on the account itself:
serverless_app capped at 15 connections, a burst of 30 concurrent requests for 5 seconds (using autocannon) produced this:
serverless_app during the burst, taken every 150ms with SELECT COUNT(*) FROM information_schema.processlist WHERE user = 'serverless_app', showed the count climbing to and past the 15-connection cap (15, 15, 16, 17, 17, 17, 17, 25 across the highest samples). Those 3614 non-2xx responses are that cap being hit. The handler above catches the error and returns it in the body of each 503, so this is what the failing responses carried:
MAX_USER_CONNECTIONS. A server with no per-user limit at all would show the same climbing count against max_connections instead, and the same error under a different name (ERROR 1040 (08004): Too many connections) once the server-wide ceiling was reached. The per-user cap is what makes this reproducible without disturbing anything else running against the same server.
The Fix: Create the Pool Once, Outside the Handler
The fix is not to stop pooling. It is to make sure the pool is created exactly once, at module load, rather than inside the function that handles each request:src/db/index.ts
src/index.ts
db/index.ts runs its top-level mysql.createPool() call exactly once, whenever the module is first imported. In a long-lived Node server that means once at process startup. On a serverless runtime whose provider reuses the execution environment for a later request without a cold start, the imported module is still in memory, so the pool from the first invocation is reused rather than recreated. Only a genuine cold start, which reloads the module from scratch, opens a new pool.
Running the same 30-concurrent, 5-second burst against this version, still with serverless_app capped at 15 connections:
connectionLimit, for every sample taken. The pool never asked MySQL for more connections than it was configured to hold open, regardless of how many concurrent requests arrived. Total throughput was also roughly three times higher over the same 5-second window (77,000 requests against 25,000). Part of that gap is the pooled version skipping a full TCP handshake and authentication round trip on every request; part of it is that many of the anti-pattern version’s 25,000 requests were fast 503 failures once the connection cap was hit, rather than a completed query, which inflates its request count without doing real work.
Beyond a Single Instance
The fix above solves the problem this guide could reproduce: concurrent requests within one running instance. It does not solve a second problem that only shows up on a real serverless platform: many separate instances cold-starting at once under a traffic spike, each running its own copy of this code and therefore opening its own pool. A pool opens connections lazily, only as concurrent requests actually need them, so ten idle instances do not open 100 connections just by existing. But once each instance is handling even modest, ordinary traffic, ten instances with aconnectionLimit of 10 each can add up to 100 connections well before the workload looks like anything unusual. This guide’s Node process could not reproduce that, because it is one process, not many.
Two approaches address it, and neither substitutes for the fix above; they are additive:
- Set a much smaller
connectionLimitper instance, often 1 or 2 rather than 10, since each concurrently running instance handles a small number of requests at a time on most serverless platforms. - Put a connection-pooling proxy in front of MySQL, such as ProxySQL, so that many separate serverless instances share a much smaller number of actual MySQL connections. See MySQL Connection Pooling for how ProxySQL fits into this.
Frequently Asked Questions
Why not just increase max_connections?
It raises the ceiling but does not remove it, and every connection carries a real memory cost on the server (see MySQL Connection Pooling for the per-connection cost breakdown). A serverless workload that can scale to hundreds of concurrent instances will eventually reach any fixed ceiling; the fix is to stop each instance from opening more connections than it needs, not to raise the number those instances are allowed to exhaust.
Does this apply to Express as well as Hono?
Yes. The failure mode is about where the pool or connection is created relative to the request handler, not about which web framework routes the request. An Express app withmysql.createConnection() called inside a route handler has the identical problem, and mysql.createPool() called once at module scope has the identical fix.
Is mysql2’s built-in pool enough, or do I need something like ProxySQL?
For a single long-lived server, mysql2’s pool is enough on its own; that is what MySQL Connection Pooling covers. On a serverless platform, the module-level pool pattern in this guide keeps one instance from opening more connections than its connectionLimit, but it cannot coordinate across separate concurrently running instances. A proxy like ProxySQL is what coordinates across instances.
Why did the fixed version’s connection count sit exactly at 10 instead of climbing to 30, matching the concurrency of the test?
Because the pool haswaitForConnections: true and a connectionLimit of 10. Once 10 connections are checked out, the eleventh concurrent request waits for one to be returned to the pool rather than opening a new one. Requests queue briefly instead of the pool growing without bound.
Troubleshooting
See also
- MySQL Connection Pooling — how pooling works on a long-lived server, and how ProxySQL coordinates pooling across multiple application servers
- Next.js and Drizzle with MySQL — the same schema and Drizzle setup on a long-lived Next.js server, without the serverless connection lifecycle
- Using MySQL with ORMs: Django, Rails, and Prisma — configuring MySQL-specific pool settings across ORMs beyond Drizzle

