Introduction
We often talk about memory leaks, but there is another silent performance killer in backend development: Database Transaction Leaks.
I recently spent time debugging a legacy codebase where a specific module’s tests passed perfectly in isolation but failed consistently when run as part of the full suite. The culprit? A "Database connection timeout" that I initially dismissed as a fluke. Here is how I discovered that our code was "leaking" database connections and how we fixed it.
The Symptom: The "Loner" Test
In isolation, my User module tests were green. However, when run alongside fifty other tests, they would suddenly time out.
I realized that the database wasn't actually slow; it was exhausted. Previous tests were opening transactions and never closing them, holding onto connections from the pool until there were none left for the subsequent tests.
The Culprit #1: The Early Return Trap
In our legacy controllers, many functions relied on manual transaction management. I found several instances where an "early return" was triggered, but the developer forgot to close the transaction before exiting the function.
The Buggy Code:
JavaScript
const t = await startTransaction();
try {
if (someCondition) {
// Early return! The transaction 't' stays open forever
// until the database or server kills the process.
return { status: 400, message: "Invalid Request" };
}
await t.commit();
} catch (e) {
await t.rollback();
}
The Culprit #2: Shared Transaction Ownership
The second issue was more subtle: Nested Transaction Suicide. A parent function would create a transaction and pass it to a child function. The child function would then commit or rollback the transaction itself. When control returned to the parent, the parent would try to commit a transaction that was already closed.
The Buggy Code:
JavaScript
async function childFunction(t) {
try {
const data = await db.create({}, { transaction: t });
await t.commit(); // Child closes the transaction
return data;
} catch (e) {
await t.rollback();
throw e;
}
}
async function parentScope() {
const t = await startTransaction();
try {
const data = await childFunction(t);
await t.commit(); // Error! The transaction is already finished.
return data;
} catch (e) {
await t.rollback();
}
}
Why didn't this break Production?
You might wonder: if we were leaking connections, why didn't the production server crash every hour?
The answer was PM2. Our production environment used PM2 to manage the Node.js processes. When the connection pool became exhausted and the app began to hang or crash, PM2 would automatically restart the instance. This cleared the "leaked" connections, acting as a temporary (and dangerous) bandage. Users just perceived this as "the app is occasionally slow."
The Solution: Proper Transaction Management
1. Explicit Lifecycle Management
Always ensure every possible code path (especially early returns) handles the transaction.
JavaScript
const t = await startTransaction();
try {
if (someCondition) {
await t.rollback(); // Always clean up before returning!
return { status: 400 };
}
await t.commit();
} catch (e) {
await t.rollback();
}
2. The "Single Owner" Principle
A good rule of thumb: The function that creates the transaction should be the one to close it. If you pass a transaction to a child function, the child should use it but never commit or rollback itself.
JavaScript
async function childFunction(t) {
// Use the transaction 't', but don't commit/rollback here
return await db.create({}, { transaction: t });
}
async function parentScope() {
const t = await startTransaction();
try {
await childFunction(t);
await t.commit(); // Only the creator manages the lifecycle
} catch (e) {
await t.rollback();
}
}
Conclusion
By fixing these transaction leaks, our test suite went from flaky and slow to stable and fast. If your tests pass individually but fail in a group, don't ignore those "Connection Timeout" errors—you might just have a leak in your database logic.
Top comments (0)