Exponential backoff with jitter is one of those patterns you rarely build on purpose and need all the time. Any call to a database, a partner API or a language model can fail now and then. The obvious reaction is a quick second attempt. At the wrong moment, though, that reflex knocks over a system that was just about to recover.

I care about this topic for two reasons. In my article on the Lambda circuit breaker, I promised that retries belong behind the breaker. On top of that, AWS switches the retry defaults of all its SDKs in November 2026, including the JavaScript SDK. So now is a good time to build retries properly: with randomness, with a time budget and with a clear plan for which layer is allowed to retry at all.

In short

Retries help with short glitches but amplify every longer outage. That is why a good retry waits exponentially longer, randomizes the wait (jitter) and stops before the Lambda function runs out of time. The most common trap sits in the architecture: when several layers retry at once, the load multiplies.

What you will learn in this article

  • Why retries without waiting and randomness overload a system
  • How full jitter works and why it is usually the best choice
  • Which errors you may retry and which never
  • A tested retry() function in TypeScript with a time budget and Retry-After
  • What changes in the AWS SDK retry defaults in November 2026

Why a quick second attempt does harm

Picture a bakery that orders flour from a mill every night. Orders go through an API. At 3 a.m. the mill API is overloaded for ten seconds and answers with 503 Service Unavailable. A hundred bakeries immediately send their order again. The API, which already had too much to do, now gets twice the load. Then three times.

This is called a thundering herd: a crowd that starts running at the same moment. A single retry is harmless. It gets critical because every client sees the same error at the same time and therefore retries at the same time as well. A short glitch turns into a long outage.

Two ingredients against the herd

Exponential backoff means the wait between two attempts doubles each time, for example 100, 200, 400 and 800 milliseconds. A cap keeps it from growing forever. As a result, the third-party system gets more breathing room the longer the problem lasts.

On its own, that is not enough. If every client waits exactly 100, 200 and 400 milliseconds, they still arrive in lockstep, just less often. This is where jitter comes in: a random component in the wait time. In 2015, Marc Brooker simulated several variants on the AWS Architecture Blog. His result is clear: backoff without jitter does worst, both in the number of calls and in total time.

🎬 Retries without and with jitter compared – this animated diagram only runs on my site: watch it on sascha-ms.de

Exponential backoff with jitter: the variants

In practice you will meet three variants. All of them start from the same upper bound min(cap, base × 2^n), where n is the number of the retry and starts at 0.

Variant Wait time Assessment
Full jitter Random value between 0 and the upper bound Little work for the server, easy to understand, my default
Equal jitter Half of the upper bound fixed, half random Avoids very short waits but did worse in the simulation
Decorrelated jitter Random value whose upper bound follows the previous value Finishes a bit faster but makes more calls

I almost always pick full jitter. The formula fits on one line, it is easy to test, and the AWS SDK uses exactly this idea in standard mode: a random number between 0 and 1 multiplied by the capped exponential wait. When the tool you already rely on computes it this way, that is a good argument for consistency.

Which errors should you retry?

A retry only makes sense when the next attempt has a real chance of success. A 400 Bad Request does not get better the second time because the request itself is broken. A 503, on the other hand, says: “Not right now, maybe later.” The following table is my rule of thumb for HTTP calls.

Situation Retry? Why
408, 429, 500, 502, 503, 504 Yes Temporary overload or failure
Timeout, dropped connection, DNS error Yes, if the call is idempotent Unclear whether the server already processed the request
400, 401, 403, 404, 422 No The request is wrong or not allowed
Bug in your own code No The next attempt fails the same way

No idempotency, no retry

The trickiest case is the timeout. Maybe the mill accepted the order and only the response got lost. A retry would then trigger two deliveries. That is why every retried write needs an idempotency key: a unique ID that stays the same on every attempt. The server uses it to recognize a request it has already seen and books it only once. How to handle this on your own side in Lambda is covered in my article on idempotency in AWS Lambda.

Respect Retry-After

Some APIs tell you directly how long to wait. The Retry-After header usually comes with 429 Too Many Requests or 503 and contains either seconds (Retry-After: 120) or an HTTP date. I treat this value as a lower bound and add the jitter on top. Clients follow the server’s guidance and still do not all return in the same millisecond.

Building exponential backoff with jitter in TypeScript

Time to get concrete. We build a small retry() function without dependencies and use it in a Lambda function that sends flour orders from an SQS queue to the (fictional) mill API. The infrastructure is described with the Serverless Framework.

Prerequisites

  • Node.js 22 and npm
  • Basic knowledge of TypeScript and AWS Lambda
  • For deployment: an AWS account, configured credentials and Serverless Framework v4 (npm i -g serverless)
  • The tests run fully locally, no AWS account needed

Step 1: Create the project

mkdir bakery-retry && cd bakery-retry npm init -y npm i -D typescript@7 vitest@5 @types/node@22 @types/aws-lambda mkdir src test 

Also add "type": "module" to package.json, plus the scripts "typecheck": "tsc --noEmit" and "test": "vitest run". The tsconfig.json stays lean:

{ "compilerOptions": { "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "skipLibCheck": true, "noEmit": true, "types": ["node"] }, "include": ["src", "test"] } 

Step 2: Compute the wait time

The fullJitterDelay function is the heart of it. We pass the random number generator in as a parameter so the tests stay reproducible.

// src/backoff.ts export interface BackoffOptions { /** Base wait time in milliseconds, e.g. 100 */ baseMs: number; /** Upper bound for a single wait, e.g. 2000 */ capMs: number; } /** * Full jitter: random wait between 0 and * min(capMs, baseMs * 2^retry). retry starts at 0. */ export function fullJitterDelay( retry: number, { baseMs, capMs }: BackoffOptions, random: () => number = Math.random, ): number { const ceiling = Math.min(capMs, baseMs * 2 ** retry); return Math.floor(random() * ceiling); } 

With baseMs: 200 and capMs: 2000, the first retry waits between 0 and 199 milliseconds, the second between 0 and 399, and from the fourth on between 0 and 1,999.

Step 3: Classify errors

Next we need an error class for HTTP responses, the “retryable or not” decision and a parser for Retry-After. Watch out for network errors: Node reports them from fetch as a TypeError with the message “fetch failed”. Any other TypeError from your own code should not be retried.

// src/http-errors.ts /** Error for an HTTP response with status and optional Retry-After */ export class HttpError extends Error { constructor( readonly status: number, readonly retryAfterMs?: number, ) { super(`HTTP ${status}`); this.name = "HttpError"; } } const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]); /** Decides whether another attempt makes sense */ export function isRetryable(error: unknown): boolean { if (error instanceof HttpError) { return RETRYABLE_STATUS.has(error.status); } if (error instanceof Error) { // Timeout from AbortSignal.timeout() or a network error from fetch // (Node reports it as a TypeError with the message "fetch failed") return error.name === "TimeoutError" || error.message === "fetch failed"; } return false; } /** * Reads the Retry-After header: seconds ("120") or an HTTP date. * Returns milliseconds, or undefined if the value is unusable. */ export function parseRetryAfter( value: string | null, now: number = Date.now(), ): number | undefined { if (!value) return undefined; const trimmed = value.trim(); if (/^\d+$/.test(trimmed)) { return Number(trimmed) * 1000; } const date = Date.parse(trimmed); if (Number.isNaN(date)) return undefined; return Math.max(0, date - now); } 

Step 4: The retry() function

The function runs the operation and, on failure, checks three things before it waits: are attempts left, is the error retryable, and does the wait still fit into the time budget? The time budget is an absolute point in time (deadline). In Lambda we derive it from the remaining execution time in a moment.

// src/retry.ts import { fullJitterDelay } from "./backoff.js"; import { HttpError, isRetryable as defaultIsRetryable } from "./http-errors.js"; export interface RetryOptions { /** Total attempts, including the first call */ maxAttempts: number; baseMs: number; capMs: number; /** Absolute point in time (ms since epoch) after which no attempt starts */ deadline: number; isRetryable?: (error: unknown) => boolean; onRetry?: (info: { attempt: number; delayMs: number; error: unknown }) => void; // Replaceable for tests sleep?: (ms: number) => Promise<void>; random?: () => number; now?: () => number; } const realSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)); export async function retry<T>( operation: (attempt: number) => Promise<T>, options: RetryOptions, ): Promise<T> { const { maxAttempts, baseMs, capMs, deadline, isRetryable = defaultIsRetryable, onRetry, sleep = realSleep, random = Math.random, now = Date.now, } = options; for (let attempt = 1; ; attempt++) { try { return await operation(attempt); } catch (error) { if (attempt >= maxAttempts || !isRetryable(error)) { throw error; } const jitter = fullJitterDelay(attempt - 1, { baseMs, capMs }, random); const serverHint = error instanceof HttpError ? error.retryAfterMs : undefined; // Retry-After is a lower bound, jitter still spreads the clients const delayMs = serverHint !== undefined ? serverHint + jitter : jitter; if (now() + delayMs >= deadline) { // Waiting would blow the time budget: give up right away throw error; } onRetry?.({ attempt, delayMs, error }); await sleep(delayMs); } } } 

Two details matter to me. First, maxAttempts counts all attempts including the first one, just like the AWS SDK does. That avoids the classic mix-up between “3 retries” and “3 attempts”. Second, the function rethrows the original error. Callers see the real reason instead of a generic “retry failed”.

Step 5: The mill API client

The client sets its own timeout per attempt via AbortSignal.timeout() and sends the same idempotency key on every attempt.

// src/mill-client.ts import { HttpError, parseRetryAfter } from "./http-errors.js"; export interface FlourOrder { orderId: string; bakeryId: string; kilograms: number; } export interface MillClientOptions { baseUrl: string; /** Timeout per attempt in milliseconds */ timeoutMs: number; } /** Sends a flour order to the (fictional) mill API */ export async function sendFlourOrder( order: FlourOrder, { baseUrl, timeoutMs }: MillClientOptions, ): Promise<{ confirmationId: string }> { const response = await fetch(`${baseUrl}/orders`, { method: "POST", headers: { "content-type": "application/json", // Same key on every attempt: the mill books only once "idempotency-key": order.orderId, }, body: JSON.stringify(order), signal: AbortSignal.timeout(timeoutMs), }); if (!response.ok) { throw new HttpError( response.status, parseRetryAfter(response.headers.get("retry-after")), ); } return (await response.json()) as { confirmationId: string }; } 

Step 6: Test it

The tests replace sleep and random, so they finish in milliseconds and always see the same wait times.

// test/retry.test.ts import { describe, expect, it } from "vitest"; import { fullJitterDelay } from "../src/backoff.js"; import { HttpError, isRetryable, parseRetryAfter } from "../src/http-errors.js"; import { retry } from "../src/retry.js"; // Shared test options: no real waiting, fixed randomness function testOptions(sleeps: number[]) { return { maxAttempts: 4, baseMs: 100, capMs: 1_000, deadline: Number.MAX_SAFE_INTEGER, sleep: async (ms: number) => { sleeps.push(ms); }, random: () => 0.5, }; } describe("fullJitterDelay", () => { it("grows exponentially and stays below the cap", () => { const opts = { baseMs: 100, capMs: 1_000 }; const max = () => 0.999999; expect(fullJitterDelay(0, opts, max)).toBe(99); expect(fullJitterDelay(3, opts, max)).toBe(799); expect(fullJitterDelay(10, opts, max)).toBe(999); }); }); describe("retry", () => { it("retries transient errors and then returns the result", async () => { const sleeps: number[] = []; let calls = 0; const result = await retry(async () => { calls++; if (calls < 3) throw new HttpError(503); return "booked"; }, testOptions(sleeps)); expect(result).toBe("booked"); expect(calls).toBe(3); expect(sleeps).toEqual([50, 100]); }); it("gives up immediately on 400", async () => { let calls = 0; await expect( retry(async () => { calls++; throw new HttpError(400); }, testOptions([])), ).rejects.toThrow("HTTP 400"); expect(calls).toBe(1); }); it("respects maxAttempts", async () => { let calls = 0; await expect( retry(async () => { calls++; throw new HttpError(500); }, testOptions([])), ).rejects.toThrow("HTTP 500"); expect(calls).toBe(4); }); it("uses Retry-After as a lower bound", async () => { const sleeps: number[] = []; let calls = 0; await retry(async () => { calls++; if (calls === 1) throw new HttpError(429, 2_000); return "ok"; }, testOptions(sleeps)); expect(sleeps).toEqual([2_050]); }); it("stops when the wait would exceed the time budget", async () => { let calls = 0; await expect( retry( async () => { calls++; throw new HttpError(429, 30_000); }, { ...testOptions([]), now: () => 0, deadline: 10_000 }, ), ).rejects.toThrow("HTTP 429"); expect(calls).toBe(1); }); }); describe("helpers", () => { it("detects retryable errors", () => { expect(isRetryable(new HttpError(503))).toBe(true); expect(isRetryable(new HttpError(404))).toBe(false); expect(isRetryable(Source:https://dev.to/sascha-ms/exponential-backoff-with-jitter-retries-in-typescript-1gk4

Related posts