Skip to content

Resilient Distributed Systems: Timeouts and Retries

How to implement timeouts, retries, and backoff strategies that prevent cascading failures in distributed architectures.

4 min read
Sequence diagram showing retry with exponential backoff between two services

In a distributed system, every network call can fail, hang, or take orders of magnitude longer than expected. A downstream service that normally responds in 50ms might take 30 seconds during a deployment, or it might never respond at all. Without timeouts and retries, a single slow service can cascade into a full system outage as threads, connections, and memory fill up waiting for responses that never come.

The challenge is not adding timeouts and retries — it is configuring them so they help during real incidents instead of making things worse.

Timeouts: The Non-Negotiable Default

Every outbound network call needs a timeout. No exceptions. A missing timeout turns a temporary downstream issue into a permanent resource leak in your service.

tstypescript
// ❌ No timeout — hangs indefinitely if downstream is slow
const response = await fetch('https://api.payment-provider.com/charge', {
  method: 'POST',
  body: JSON.stringify(payload),
});
 
// ✅ Explicit timeout — fails fast when downstream is unresponsive
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
 
try {
  const response = await fetch('https://api.payment-provider.com/charge', {
    method: 'POST',
    body: JSON.stringify(payload),
    signal: controller.signal,
  });
  return await response.json();
} catch (error) {
  if (error instanceof DOMException && error.name === 'AbortError') {
    throw new TimeoutError('Payment provider did not respond within 5s');
  }
  throw error;
} finally {
  clearTimeout(timeout);
}

Choosing Timeout Values

tstypescript
interface TimeoutConfig {
  // Connect timeout: how long to wait for TCP connection
  connectTimeout: number;
  // Read timeout: how long to wait for response after connected
  readTimeout: number;
  // Total timeout: maximum wall-clock time for the entire operation
  totalTimeout: number;
}
 
// ❌ Timeout too high — defeats the purpose
const bad: TimeoutConfig = {
  connectTimeout: 60_000,
  readTimeout: 120_000,
  totalTimeout: 180_000,
};
 
// ✅ Based on p99 latency + margin
const good: TimeoutConfig = {
  connectTimeout: 1_000,    // Most connects finish in <100ms
  readTimeout: 3_000,       // p99 is 800ms, 3x margin
  totalTimeout: 5_000,      // Hard ceiling for the operation
};

Set timeouts based on the observed p99 latency of the downstream service, not the average. A timeout at 3x the p99 catches genuine failures while allowing normal latency variation.

Retry Strategies

Retries handle transient failures — network blips, brief service restarts, temporary overload. But naive retries amplify problems instead of solving them.

tstypescript
// ❌ Immediate retry — hammers a struggling service
async function naiveRetry<T>(fn: () => Promise<T>, maxRetries: number): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries) throw error;
      // Retries instantly — adds load to an already struggling service
    }
  }
  throw new Error('Unreachable');
}
 
// ✅ Exponential backoff with jitter — backs off and spreads retry load
async function retryWithBackoff<T>(
  fn: () => Promise<T>,
  options: {
    maxRetries: number;
    baseDelayMs: number;
    maxDelayMs: number;
  }
): Promise<T> {
  const { maxRetries, baseDelayMs, maxDelayMs } = options;
 
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries) throw error;
      if (!isRetryable(error)) throw error;
 
      const exponentialDelay = baseDelayMs * Math.pow(2, attempt);
      const jitter = Math.random() * exponentialDelay;
      const delay = Math.min(exponentialDelay + jitter, maxDelayMs);
 
      await sleep(delay);
    }
  }
  throw new Error('Unreachable');
}

Jitter Prevents Thundering Herds

Without jitter, all clients retry at the same time — creating a synchronized burst that overwhelms the recovering service.

tstypescript
// Without jitter: 1000 clients all retry at exactly t+1s, t+2s, t+4s
// With jitter: 1000 clients retry spread over [0-1s], [0-2s], [0-4s]
 
function calculateBackoff(attempt: number, baseMs: number, maxMs: number): number {
  // Full jitter — recommended by AWS
  const ceiling = Math.min(maxMs, baseMs * Math.pow(2, attempt));
  return Math.random() * ceiling;
}
 
// Decorrelated jitter — even better spread
function decorrelatedJitter(
  previousDelay: number,
  baseMs: number,
  maxMs: number
): number {
  return Math.min(maxMs, baseMs + Math.random() * (previousDelay * 3 - baseMs));
}

Retry Budgets

In a microservice chain, retries multiply. If A calls B with 3 retries and B calls C with 3 retries, a failure at C generates up to 9 requests from B and 27 from A. This amplification can turn a small failure into a system-wide overload.

tstypescript
class RetryBudget {
  private attempts = 0;
  private successes = 0;
 
  // Only retry if retry rate is below threshold
  canRetry(): boolean {
    if (this.attempts === 0) return true;
 
    const retryRate = 1 - (this.successes / this.attempts);
    return retryRate < 0.2; // Max 20% retry rate
  }
 
  recordAttempt(): void {
    this.attempts++;
  }
 
  recordSuccess(): void {
    this.successes++;
  }
 
  // Reset counters periodically
  reset(): void {
    this.attempts = 0;
    this.successes = 0;
  }
}

A retry budget caps the total retry rate across all requests, not per-request. When the system is healthy, the budget is generous. When failures spike, the budget tightens — preventing retry storms.

Idempotency for Safe Retries

Retries are only safe if the operation is idempotent — executing it twice produces the same result as executing it once. Without idempotency, retries can duplicate charges, create double entries, or send multiple emails.

tstypescript
// ❌ Not idempotent — retry creates duplicate payment
app.post('/charge', async (req, res) => {
  const result = await paymentProvider.charge(req.body.amount);
  await db.payments.create({ amount: req.body.amount, txId: result.id });
  return res.json({ success: true });
});
 
// ✅ Idempotent — uses idempotency key to prevent duplicates
app.post('/charge', async (req, res) => {
  const idempotencyKey = req.headers['idempotency-key'] as string;
  if (!idempotencyKey) {
    return res.status(400).json({ error: 'Idempotency-Key header required' });
  }
 
  // Check if this request was already processed
  const existing = await db.idempotencyKeys.findUnique({
    where: { key: idempotencyKey },
  });
  if (existing) {
    return res.json(existing.response);
  }
 
  const result = await paymentProvider.charge(req.body.amount);
  const response = { success: true, txId: result.id };
 
  await db.idempotencyKeys.create({
    data: { key: idempotencyKey, response },
  });
 
  return res.json(response);
});

Timeout Hierarchy in Service Chains

In a call chain (API Gateway → Service A → Service B → Database), timeouts must decrease at each hop. If Service A has a 5s timeout and Service B has a 10s timeout, B might still be working when A gives up — wasting resources.

tstypescript
// Service chain timeout configuration
const timeouts = {
  apiGateway: 10_000,   // 10s — highest timeout, user-facing
  serviceA:    7_000,   //  7s — must finish before gateway timeout
  serviceB:    4_000,   //  4s — must finish before A's timeout
  database:    2_000,   //  2s — must finish before B's timeout
};
 
// Each level leaves headroom for retries and processing
// Gateway (10s) > A (7s) > B (4s) > DB (2s)

Deadline Propagation

Instead of each service setting independent timeouts, propagate a deadline from the originating request.

tstypescript
interface RequestContext {
  deadline: number; // Unix timestamp when the entire chain must complete
  correlationId: string;
}
 
async function callDownstream(ctx: RequestContext, url: string): Promise<Response> {
  const remainingMs = ctx.deadline - Date.now();
 
  if (remainingMs <= 0) {
    throw new DeadlineExceeded('No time remaining for downstream call');
  }
 
  // Use remaining time as timeout, with safety margin
  const timeout = Math.max(remainingMs - 100, 0);
 
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeout);
 
  try {
    return await fetch(url, {
      headers: {
        'X-Request-Deadline': String(ctx.deadline),
        'X-Correlation-Id': ctx.correlationId,
      },
      signal: controller.signal,
    });
  } finally {
    clearTimeout(timer);
  }
}

Deadline propagation ensures that downstream services do not start work they cannot finish before the caller gives up.

Key Takeaways

  1. Every network call needs a timeout — a missing timeout turns transient failure into permanent resource exhaustion
  2. Base timeouts on p99, not average — set to 2-3x the observed p99 latency
  3. Always use exponential backoff with jitter — prevents thundering herd on the recovering service
  4. Retry budgets prevent amplification — cap total retry rate, not just per-request retries
  5. Retries require idempotency — without it, retries duplicate side effects
  6. Timeouts decrease down the call chain — and deadline propagation enforces this automatically
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX