Tiempos de espera, reintentos y backoff exponencial con jitter
Limite todas las llamadas salientes y reintente los fallos transitorios sin amplificar la carga sobre las dependencias.
Tiempos de espera, reintentos y backoff exponencial con jitter es una lección gratuita de Node.js Backend Development Bootcamp en CoddyKit. Esta es la lección 1 de 4. Puedes leer la lección completa abajo gratuitamente — luego la practicas en el navegador con un editor de código integrado y un tutor de IA 24/7. Forma parte de la ruta de aprendizaje de Node.js Backend Development Bootcamp, y tu progreso se sincroniza en la web y la app de CoddyKit. El curso de Node.js Backend Development Bootcamp incluye 4 lecciones en total.
Partes de esta lección aún no han sido traducidas y se muestran en inglés.
Why Bound Every Outbound Call
In a Node.js backend, every call that leaves your process (HTTP request, database query, message broker publish) is a place where you can hang forever. A slow dependency does not just delay one request, it pins an event-loop tick, holds a socket, and keeps a connection-pool slot busy.
- Unbounded latency cascades: one stuck downstream call multiplies into thousands of stuck inbound requests.
- Resource exhaustion: sockets, file descriptors, and pool connections leak while you wait.
- No SLA without a deadline: you cannot promise a p99 latency if a call has no upper bound.
The first rule of production resilience: never make an outbound call without a timeout. Retries and backoff build on top of that bound.
Timeouts with AbortSignal.timeout
Modern Node.js (18+) ships AbortSignal.timeout(ms), which produces a signal that aborts automatically after the deadline. The global fetch accepts a signal, so bounding an HTTP call is a one-liner.
- When the timeout fires, the promise rejects with an
AbortError(err.name === 'AbortError'). - The signal is fire-and-forget: no manual
clearTimeoutneeded. - Always distinguish a timeout abort from other network errors so you log and retry correctly.
async function fetchWithTimeout(url, ms) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(ms) });
if (!res.ok) throw new Error('HTTP ' + res.status);
return await res.json();
} catch (err) {
if (err.name === 'AbortError' || err.name === 'TimeoutError') {
throw new Error('Request timed out after ' + ms + 'ms');
}
throw err;
}
}
// Demo against a hung promise (no real network)
function hang(signal) {
return new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason));
});
}
(async () => {
try {
await hang(AbortSignal.timeout(50));
} catch (e) {
console.log('Aborted:', e.name);
}
})();Connect, Read, and Total Timeouts Are Different
"Timeout" is not a single number. A robust client distinguishes several deadlines:
- Connect timeout: how long to wait for the TCP/TLS handshake.
- Read / socket-idle timeout: how long to wait between bytes once connected.
- Total / overall timeout: a hard wall on the entire operation, including retries.
A common bug is setting only a read timeout. A dependency that accepts the connection but never sends the first byte can still stall up to the socket-idle limit. The total budget is the one your caller actually feels, so always cap the whole retry sequence, not just each attempt.
Rule of thumb: perAttemptTimeout x maxAttempts should stay under your overall request budget, or you will blow your own SLA while retrying.
Retry Only Transient, Idempotent Failures
Retrying is dangerous if applied blindly. You must classify the failure before retrying:
- Retryable (transient): connection reset, DNS hiccup, timeout, HTTP
502/503/504, and429(respectingRetry-After). - NOT retryable:
400(bad request),401/403(auth),404,422. Retrying these just wastes load and never succeeds. - Idempotency matters: a
GETorPUTis safe to repeat; a non-idempotentPOST(charge a card) can double-execute. Use an idempotency key before retrying writes.
function isRetryable(err) {
// Network-level errors thrown by Node
const transientCodes = new Set([
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN'
]);
if (err.code && transientCodes.has(err.code)) return true;
if (err.name === 'AbortError' || err.name === 'TimeoutError') return true;
// HTTP status carried on the error
if (err.status && [429, 502, 503, 504].includes(err.status)) return true;
return false;
}
console.log(isRetryable({ code: 'ECONNRESET' })); // true
console.log(isRetryable({ status: 503 })); // true
console.log(isRetryable({ status: 404 })); // false
console.log(isRetryable(new Error('bad json'))); // falseFixed Delay Retries Are Not Enough
The naive retry waits a constant delay between attempts:
- If a dependency is briefly overloaded, all your clients retry at the same fixed interval and hit it again together.
- This creates a retry storm: the very act of retrying keeps the dependency down.
- A constant short delay also wastes attempts when the outage lasts seconds, while a constant long delay wastes time on quick blips.
The fix is to grow the wait after each failure (exponential backoff) so pressure on the dependency decreases over time, and to add randomness (jitter) so clients do not synchronize. The next scenes build this up.
Exponential Backoff
Exponential backoff multiplies the delay after each failed attempt, typically by a base of 2:
delay = base * 2^attempt, e.g. with base 100ms: 100, 200, 400, 800ms.- Always cap the delay with a
maxDelayso it does not grow to minutes. - The cap turns pure exponential growth into "capped exponential backoff", which is what you almost always want.
The intuition: a quick first retry catches one-off blips, while later attempts back off hard to give a struggling dependency room to recover.
function backoffDelay(attempt, base = 100, maxDelay = 2000) {
const exp = base * 2 ** attempt;
return Math.min(exp, maxDelay);
}
for (let attempt = 0; attempt < 6; attempt++) {
console.log('attempt', attempt, '->', backoffDelay(attempt) + 'ms');
}
// 0->100, 1->200, 2->400, 3->800, 4->1600, 5->2000 (capped)The Thundering Herd Problem
Pure exponential backoff still has a flaw: if 1,000 clients all failed at the same instant (a shared dependency blipped), they all compute the same delay and retry at the same future moment.
- The dependency recovers, then gets hit by 1,000 simultaneous retries, and falls over again.
- This synchronized wave is the thundering herd.
- Capping the delay does not help, it just synchronizes the herd at the cap.
The cure is jitter: add randomness to each client's delay so the retries spread out across a window instead of landing on the same tick. Jitter is not optional polish, it is the part that actually protects the dependency.
Full Jitter
The AWS-recommended strategy is full jitter: compute the capped exponential ceiling, then pick a uniformly random delay between 0 and that ceiling.
cap = min(maxDelay, base * 2^attempt)delay = random(0, cap)
This spreads retries evenly across the whole window, minimizing collisions. Compared to "equal jitter" (half fixed + half random), full jitter generally yields the lowest contention and fewest total calls under load. The small cost is that an individual retry can fire very early, which is fine because the goal is to de-synchronize the herd.
function fullJitterDelay(attempt, base = 100, maxDelay = 2000) {
const cap = Math.min(maxDelay, base * 2 ** attempt);
return Math.floor(Math.random() * cap);
}
// Show how 5 "clients" spread out on the same attempt
for (let client = 0; client < 5; client++) {
console.log('client', client, 'attempt 3 delay:', fullJitterDelay(3) + 'ms');
}
// Each client gets a different value in [0, 800)Putting It Together: retryWithBackoff
Now combine bounding, classification, capped exponential backoff, and full jitter into one reusable helper. Key design points:
- Each attempt is independently bounded by a timeout.
- Only retryable errors trigger another attempt; everything else throws immediately.
- After the last attempt, rethrow so the caller can fail fast.
- An overall deadline (not shown here) should still wrap the whole loop in production.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
function fullJitter(attempt, base = 100, maxDelay = 2000) {
const cap = Math.min(maxDelay, base * 2 ** attempt);
return Math.floor(Math.random() * cap);
}
async function retryWithBackoff(fn, { retries = 4, isRetryable } = {}) {
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn(attempt);
} catch (err) {
lastErr = err;
if (attempt === retries || !isRetryable(err)) throw err;
const delay = fullJitter(attempt);
console.log('attempt', attempt, 'failed, waiting', delay + 'ms');
await sleep(delay);
}
}
throw lastErr;
}
// Simulate a flaky call that succeeds on the 3rd try
let calls = 0;
retryWithBackoff(
async () => {
calls++;
if (calls < 3) { const e = new Error('flaky'); e.status = 503; throw e; }
return 'ok after ' + calls + ' calls';
},
{ retries: 4, isRetryable: (e) => e.status === 503 }
).then(console.log);Respect Retry-After and Cap the Total Budget
Two production-grade refinements make the difference between a polite client and a load amplifier:
- Honor
Retry-After: on429or503, the server may tell you exactly how long to wait. Always prefer that value over your computed backoff, it is the dependency asking for room. - Enforce an overall deadline: track a budget (e.g. 3s). Before sleeping, if
now + delaywould exceed the deadline, stop retrying and fail fast instead of blowing your SLA.
The combination keeps each retry bounded, the total bounded, and lets the dependency steer its own recovery.
function nextDelay(err, attempt, base = 100, maxDelay = 5000) {
const header = err.retryAfterSeconds; // parsed from Retry-After
if (typeof header === 'number') return header * 1000;
const cap = Math.min(maxDelay, base * 2 ** attempt);
return Math.floor(Math.random() * cap);
}
const deadline = Date.now() + 3000; // 3s total budget
let attempt = 0;
const err = { status: 429, retryAfterSeconds: 1 };
const delay = nextDelay(err, attempt);
if (Date.now() + delay > deadline) {
console.log('Budget exhausted, fail fast');
} else {
console.log('Honoring Retry-After, sleeping', delay + 'ms');
}Retries Need a Circuit Breaker Above Them
Retries handle transient failures. They are the wrong tool for a sustained outage: if a dependency is hard-down, every request retrying 4 times multiplies your outbound load by 5x at the worst possible moment.
- Layer a circuit breaker above the retry helper. When failures cross a threshold, the breaker opens and short-circuits calls instantly (fail fast) instead of retrying.
- After a cool-down it goes half-open, lets a probe through, and closes again on success.
- Order of layers: breaker -> retry -> timeout. The timeout bounds each attempt, retry handles blips, the breaker stops the bleeding during real outages.
This is the full resilience stack for outbound calls in a Node.js backend.
Quick Check
You operate a Node.js service whose downstream payment provider briefly returns 503 during a deploy. Thousands of your instances all retry. Which single change most directly prevents your retries from re-overloading the provider the instant it recovers?
Recap
You learned how to bound and retry outbound calls without amplifying load:
- Bound everything: no outbound call without a timeout; distinguish connect, read, and total deadlines via
AbortSignal.timeout. - Classify before retrying: retry only transient, idempotent failures (timeouts,
ECONNRESET,429/502/503/504); never retry400/401/404/422. - Capped exponential backoff: grow the delay after each failure, but cap it with
maxDelay. - Full jitter: pick a random delay in
[0, cap)to break the thundering herd. This is the part that protects the dependency. - Respect
Retry-Afterand a total budget: let the server steer recovery and fail fast before blowing your SLA. - Top it with a circuit breaker: breaker -> retry -> timeout handles outages, blips, and slow calls respectively.
Preguntas frecuentes
¿La lección «Tiempos de espera, reintentos y backoff exponencial con jitter» es gratis?
Sí — el texto completo de «Tiempos de espera, reintentos y backoff exponencial con jitter» es gratis para leer aquí en la web. Para practicarla de forma interactiva (editor de código integrado y tutor de IA 24/7) y desbloquear el resto del curso de Node.js Backend Development Bootcamp, actualiza a CoddyKit PRO. El curso de Node.js Backend Development Bootcamp incluye 4 lecciones en total.
¿Qué aprenderé en «Tiempos de espera, reintentos y backoff exponencial con jitter»?
Limite todas las llamadas salientes y reintente los fallos transitorios sin amplificar la carga sobre las dependencias. Practicas Node.js Backend Development Bootcamp con código real que ejecutas directamente en el navegador, y un tutor de IA 24/7 responde tus preguntas mientras trabajas en la lección.
¿Necesito experiencia previa para empezar Node.js Backend Development Bootcamp?
No se requiere experiencia previa. Node.js Backend Development Bootcamp en CoddyKit está estructurado para principiantes hasta estudiantes avanzados, así que puedes empezar aquí o desde el inicio y avanzar a tu ritmo. Esta es la lección 1 de 4.
¿Cuánto tiempo toma la lección «Tiempos de espera, reintentos y backoff exponencial con jitter»?
La mayoría de las lecciones de CoddyKit toman alrededor de 5–10 minutos. Cada una es compacta e interactiva, así que avanzas constantemente y retomas exactamente por donde dejaste en la web y la app.
¿Puedo escribir y ejecutar código en esta lección de Node.js Backend Development Bootcamp?
Sí. Cada lección de Node.js Backend Development Bootcamp incluye un editor de código integrado, así que escribes y ejecutas código real directamente en tu navegador y obtienes retroalimentación instantánea de IA — sin configuración local necesaria.
Todas las lecciones de este curso
- Tiempos de espera, reintentos y backoff exponencial con jitter
- Patrón de circuit breaker y aislamiento bulkhead
- Apagado controlado y drenaje de solicitudes en curso
- Comprobaciones de salud, sondas de disponibilidad y load shedding