Why Awaiting an External API Inside a Database Transaction Will Exhaust Your Connection Pool 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 Intuitive Mistake: Coupling Network I/O to ACID Transactions The impulse to wrap network requests inside transactions usually comes from wanting ACID guarantees (Atomicity, Consistency, Isolation, Durability) across an entire multi-step process: await db.transaction(async (trx) => { await trx('orders').where({ id: orderId }).update({ status: 'processing' }); 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. Under the Hood: How Idle-in-Transaction Connections Exhaust the Pool 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 paused 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: Baseline State: The external API responds in ~150ms. A pool of 20 connections handles incoming traffic smoothly because connections are checked out and released quickly. The Latency Spike: The third-party service experiences delays, and response times jump to 4,000ms. Pool Saturation: New requests keep arriving. In less than a second, all 20 connections in the pool are checked out, sitting idle while waiting for HTTP responses. Cascading Failure: Every query in the service—including health checks, unrelated reads, and auth checks—begins waiting in line. Eventually, connection acquire timeouts start throwing errors across unrelated parts of the app. Pattern 1: Shrinking Transaction Lifecycles Around Pure Database Work 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: status: 'pending_external_call', updated_at: new Date(), let apiResponse; catch (error) { await db('orders').where({ id: orderId }).update({ status: 'failed', last_error: error.message, throw error; } external_reference: apiResponse.id, 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. Pattern 2: Handling Downstream Failures with Compensating Actions and Retries 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: Compensating Actions: When the external call fails with an explicit rejection (like a 4xx client error), run a targeted update to mark the record as failed or revert previously reserved values. Background Reconciliation: Run a background worker to query for records stuck in 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. Idempotency Keys: Pass deterministic idempotency keys (such as 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. Defensive Guardrails: Setting Strict Query and Timeout Limits Even after refactoring network calls outside transactions, safety configurations help catch lingering connections and runaway queries: HTTP Client Timeouts: Always set strict request timeouts using an AbortController or your HTTP client's configuration. A request should never hang indefinitely waiting for headers or response data. Database Statement Timeouts: Configure statement_timeout on your database connection so long-running queries fail before tying up the pool permanently. Idle-in-Transaction Timeouts: If your database supports it (such as PostgreSQL's idle_in_transaction_session_timeout), configure an explicit threshold. If an open transaction sits inactive past that limit, the database terminates it automatically. Connection Pool Acquire Timeout: Configure a clear timeout for checking out a connection from the pool so calls fail fast with an actionable error instead of hanging indefinitely. 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.
Why Awaiting an External API Inside a Database Transaction Will Exhaust Your Connection Pool