DEV Community

omoogun olawale
omoogun olawale

Posted on

Database Transaction Leak

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();
 }
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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)