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();fetchrejects only on network failure. A 500 is a resolved promise withok === 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, callres.body?.cancel()so the connection returns to the pool.- The built-in client (undici) pools connections globally; creating a custom
Agentlets 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 }));| Failure | Retry? | Why |
|---|---|---|
| Connection reset, timeout | Yes | Transient network or upstream restart |
| 429 Too Many Requests | Yes, honour Retry-After | You are being rate limited deliberately |
| 500 to 504 | Yes, with backoff | Usually an upstream fault |
| 400, 401, 403, 404 | No | The request will fail the same way every time |
| 409 Conflict | Only with an idempotency key | State 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: 10can 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.
Related
Testing with the built-in test runner Security, performance and deployment
Last refreshed 2026-09-18.