While working on a Node.js backend that handled database transactions alongside third-party API integrations, I made an architectural mistake that initially felt completely reasonable: I opened a database transaction, ran an update, used await to call an external API, and committed the transaction once the remote response came back.
My reasoning was simple: I wanted the operation to stay consistent. If the external API failed or returned an error, the catch block would trigger a rollback, preventing our local database state from drifting away from the third-party service.
Everything passed during normal testing. The database updated, the API responded quickly, and the transaction committed. But I eventually realized the danger of this design: if that external service slows down or becomes temporarily unavailable, the transaction stays open the entire time it waits for a response. Because database connections stay occupied for far longer than necessary, concurrent requests quickly start waiting in line for available connections, choking the whole system.
Here is why network I/O inside database transactions creates connection pool bottlenecks and how to decouple your database writes from third-party services safely.
The impulse to wrap network requests inside transactions usually comes from wanting ACID guarantees (Atomicity, Consistency, Isolation, Durability) across an entire multi-step process:
// The Anti-Pattern: Awaiting third-party network I/O inside a transaction
await db.transaction(async (trx) => {
// 1. Mutate internal state
await trx('orders').where({ id: orderId }).update({ status: 'processing' });
// 2. High-latency network I/O holding the lock and connection
const response = await externalPaymentGateway.charge({
amount: 5000,
orderId,
});
// 3. Commit only if the external call succeeds
await trx('orders').where({ id: orderId }).update({
status: 'completed',
transactionId: response.id,
});
});
While this cleanly handles error catching when an API fails quickly, it conflates database transactions with distributed operations.
A database transaction coordinates operations strictly within your database engine, usually over a few milliseconds. The moment you introduce an external HTTP request, you link your database's concurrency limit to third-party server load, internet latency, and DNS lookups.
To see why this causes problems across an application, look at how connection pools work.
When your Node.js application executes a query, it borrows a connection from a finite pool (often configured with 10 to 50 connections depending on the service). A single read or write checks out a connection, executes in a couple of milliseconds, and immediately releases it back to the pool.
When you start a transaction (BEGIN), the pool assigns a dedicated connection to that specific transaction until it either executes COMMIT or ROLLBACK.
While Node.js is d on await externalApi.post(...), the connection sits in an idle in transaction state. The database engine does not know that your server is just waiting on network packets. As far as the engine is concerned, a client has reserved an active session and may be holding open row or table locks.
Consider what happens during a latency spike under moderate traffic:
The fix is to minimize transaction duration by separating database updates from external network requests. A transaction should open, execute only database operations, and commit immediately.
Instead of keeping the transaction open during the network request, split the lifecycle into distinct steps using explicit state tracking:
// Step 1: Open a fast transaction, reserve state, and commit immediately
await db.transaction(async (trx) => {
await trx('orders').where({ id: orderId }).update({
status: 'pending_external_call',
updated_at: new Date(),
});
});
// Step 2: Make the network request outside any transaction
let apiResponse;
try {
apiResponse = await externalApi.sendRequest({ orderId });
} catch (error) {
// Handle explicit network or API failure
await db('orders').where({ id: orderId }).update({
status: 'failed',
last_error: error.message,
});
throw error;
}
// Step 3: Open a second fast transaction to record the completed result
await db.transaction(async (trx) => {
await trx('orders').where({ id: orderId }).update({
status: 'completed',
external_reference: apiResponse.id,
updated_at: new Date(),
});
});
Here, connections are held only for the fraction of a millisecond required to execute the write. If the external API takes five seconds to respond, zero database connections are tied up waiting on it.
Once network operations live outside the database transaction boundary, you no longer get automatic database rollbacks if the remote call fails mid-flight. If the process terminates unexpectedly right after Step 2, a row might stay in pending_external_call.
Handling this requires explicit failure handling and retry strategies:
pending_external_call past a sensible threshold (such as older than 5 minutes). The worker can verify status against the remote provider and resolve the local record.orderId) in your API requests. If a network timeout occurs and your background retry worker re-sends the request, the third-party API knows not to execute duplicate operations.
As highlighted in real-world deployment patterns—like this discussion on handling edge-case failures before running production workflows—robust systems plan for network dropouts instead of assuming a single synchronous block can guarantee consistency.
Even after refactoring network calls outside transactions, safety configurations help catch lingering connections and runaway queries:
AbortController or your HTTP client's configuration. A request should never hang indefinitely waiting for headers or response data.statement_timeout on your database connection so long-running queries fail before tying up the pool permanently.idle_in_transaction_session_timeout), configure an explicit threshold. If an open transaction sits inactive past that limit, the database terminates it automatically.
Keeping database transactions scoped strictly to database work keeps your connections free. Isolating network I/O ensures that a slow third-party service only impacts the specific feature calling it, rather than exhausting connection resources across your entire application.