Databases, HTTP clients and external services

fetch with timeouts, retries and idempotency, connection pooling, migrations and transaction boundaries.

Outbound HTTP with fetch

const res = await fetch("https://api.example.com/orders/1042", {
  signal: AbortSignal.timeout(2000),          // Node 17.3+
  headers: { accept: "application/json" },
});

if (!res.ok) {
  const detail = (await res.text()).slice(0, 200);
  throw new Error("upstream " + res.status + ": " + detail);
}

const order = await res.json();
  • fetch rejects only on network failure. A 500 is a resolved promise with ok === false, so every call must check the status.
  • AbortSignal.timeout(ms) is the shortest correct timeout; without one a request can hang until the socket layer gives up minutes later.
  • res.json() consumes the body once. If you ignore the body, call res.body?.cancel() so the connection returns to the pool.
  • The built-in client (undici) pools connections globally; creating a custom Agent lets you tune keep-alive, per-host limits and HTTP/2.
  • Send a user-agent and, when a service supports it, a request id header — support questions get much easier to answer.

Retries and idempotency

async function withRetry(fn, { attempts = 4, base = 200 } = {}) {
  for (let i = 0; ; i++) {
    try {
      return await fn();
    } catch (err) {
      const retryable =
        err.code === "ECONNRESET" || err.status === 429 || (err.status ?? 0) >= 500;
      if (!retryable || i === attempts - 1) throw err;

      const delay = base * 2 ** i + Math.random() * base;   // exponential backoff + jitter
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

await withRetry(() => charge({ idempotencyKey: order.id }));
FailureRetry?Why
Connection reset, timeoutYesTransient network or upstream restart
429 Too Many RequestsYes, honour Retry-AfterYou are being rate limited deliberately
500 to 504Yes, with backoffUsually an upstream fault
400, 401, 403, 404NoThe request will fail the same way every time
409 ConflictOnly with an idempotency keyState changed underneath you
  • Retry only operations that are safe to repeat, or send a key the server uses to deduplicate.
  • Jitter matters more than the base delay: without it every client retries at the same instant and the upstream never recovers.
  • Bound the attempts and the total time, and surface the last error rather than a generic timeout.
  • A circuit breaker stops the retry storm entirely once a dependency is clearly down.

Pooling, migrations and transactions

import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });

const client = await pool.connect();
try {
  await client.query("BEGIN");
  await client.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [10, 1]);
  await client.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [10, 2]);
  await client.query("COMMIT");
} catch (err) {
  await client.query("ROLLBACK");
  throw err;
} finally {
  client.release();          // always return the connection to the pool
}
  • Use parameterised queries. String concatenation is an injection bug the moment any part of the string comes from a user.
  • Pool size is per process, so ten replicas with max: 10 can demand a hundred connections from a database sized for thirty.
  • Migrations belong in the repository, are applied once, and must be safe to run while the previous version is still serving traffic.
  • Keep a transaction short and never hold one open across an HTTP call to another service; it pins a connection and locks rows.
  • Use Redis for caching, rate-limit counters and queues, and always give keys a TTL — a cache without expiry is a slow memory leak.
⚠️
A missing timeout on a database call is worse than a slow query: the connection stays checked out, the pool drains, and every later request queues behind it. Set statement and connection timeouts at the client and at the server.

FAQ

How many database connections should a service open?
Start near the number of CPU cores it can use, often 5 to 10, and measure. More connections add contention rather than throughput past a point, and the limit is shared across every replica.
Why must an idempotency key be generated by the client?
Only the client knows that two requests are the same logical operation. A server-side retry of an ambiguous timeout cannot tell a lost response from a lost request, so it needs the key to decide whether to charge again.

Testing with the built-in test runner Security, performance and deployment

Last refreshed 2026-09-18.