Applications and operations
Use a bounded connection pool
Budget connections across every process and keep all transaction statements on one checked-out connection with reliable cleanup.
Opening a MySQL connection costs a TCP handshake, a TLS negotiation, and authentication. Paying that on every query would dominate the query time itself. A pool avoids repeated connection setup and queues work when all its connections are busy — that queue is what makes the pool bounded, and the bound is what protects the server.
The server enforces its own limit: max_connections, 151 by default. When the combined demand of every client exceeds it, new connections fail with ERROR 1040 (HY000): Too many connections — including yours, when you try to connect and investigate.
So count the pool size across every application process, not per file. Ten replicas with a pool of ten can open one hundred connections, before workers, migrations, and administration. Check real usage on the server anytime:
SHOW STATUS LIKE 'Threads_connected';
Compare that number with max_connections and keep headroom for an emergency administrative session.
Transactions need one connection
A transaction must stay on one checked-out connection, because MySQL scopes the transaction to the connection that started it:
const connection = await pool.getConnection()
try {
await connection.beginTransaction()
const [note] = await connection.execute(
'INSERT INTO notes (title) VALUES (?)',
['Plan the week']
)
await connection.execute(
'INSERT INTO note_tags (note_id, tag_id) VALUES (?, ?)',
[note.insertId, 1]
)
await connection.commit()
} catch (error) {
await connection.rollback()
throw error
} finally {
connection.release()
}
Do not call pool.execute() inside that transaction. The pool may choose another connection, and that statement would run outside the transaction — committed immediately, invisible to your rollback.
The finally block is not decoration. Always roll back after an error and release in finally. A code path that returns or throws without connection.release() leaks that connection: the pool believes it is still busy forever. Leak a few and every request starts waiting on an empty pool, which looks exactly like a database outage. If the application slows down while Threads_connected sits at your pool limit and the database itself is idle, look for a missing release() before anything else.
Lesson completed