DEV Community

Cover image for Exponential backoff with jitter: retries in TypeScript
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

Exponential backoff with jitter: retries in TypeScript

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
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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(new TypeError("fetch failed"))).toBe(true);
    expect(isRetryable(new TypeError("x is undefined"))).toBe(false);
    expect(isRetryable(new DOMException("timeout", "TimeoutError"))).toBe(true);
  });

  it("parses Retry-After as seconds and as a date", () => {
    const now = Date.parse("Tue, 27 Oct 2026 08:00:00 GMT");
    expect(parseRetryAfter("120", now)).toBe(120_000);
    expect(parseRetryAfter("Tue, 27 Oct 2026 08:00:30 GMT", now)).toBe(30_000);
    expect(parseRetryAfter("soon", now)).toBeUndefined();
    expect(parseRetryAfter(null, now)).toBeUndefined();
  });
});
Enter fullscreen mode Exit fullscreen mode
npx tsc --noEmit
npx vitest run
Enter fullscreen mode Exit fullscreen mode

Expected result:

 Test Files  1 passed (1)
      Tests  8 passed (8)
Enter fullscreen mode Exit fullscreen mode

The example project also contains a test with a local HTTP server that answers twice with 503. It checks that all three attempts carry the same idempotency key and that a connection error is recognized as retryable.

Retries in Lambda with a time budget

Lambda adds one twist: the function has a hard timeout. If a function with 30 seconds of runtime stubbornly retries five times with growing waits, it eventually gets killed in the middle of a wait. Then nobody knows whether the order went through, and no clean error reaches the log. For that reason, the handler computes a deadline from context.getRemainingTimeInMillis() and keeps some headroom.

// src/handler.ts
import type { SQSBatchResponse, SQSEvent, Context } from "aws-lambda";
import { retry } from "./retry.js";
import { sendFlourOrder, type FlourOrder } from "./mill-client.js";

const MILL_API_URL = process.env.MILL_API_URL ?? "https://mill.example.com";
// Headroom so logging and the response to Lambda still fit into the runtime
const SAFETY_MARGIN_MS = 2_000;
const TIMEOUT_PER_ATTEMPT_MS = 3_000;

export async function handler(
  event: SQSEvent,
  context: Context,
): Promise<SQSBatchResponse> {
  const deadline = Date.now() + context.getRemainingTimeInMillis() - SAFETY_MARGIN_MS;
  const batchItemFailures: SQSBatchResponse["batchItemFailures"] = [];

  for (const record of event.Records) {
    const order = JSON.parse(record.body) as FlourOrder;
    if (Date.now() + TIMEOUT_PER_ATTEMPT_MS > deadline) {
      // No budget left for a full attempt: back to the queue
      batchItemFailures.push({ itemIdentifier: record.messageId });
      continue;
    }
    try {
      const result = await retry(
        () => sendFlourOrder(order, { baseUrl: MILL_API_URL, timeoutMs: TIMEOUT_PER_ATTEMPT_MS }),
        {
          maxAttempts: 3,
          baseMs: 200,
          capMs: 2_000,
          deadline,
          onRetry: ({ attempt, delayMs, error }) =>
            console.warn(JSON.stringify({
              msg: "retrying flour order",
              orderId: order.orderId,
              attempt,
              delayMs,
              error: String(error),
            })),
        },
      );
      console.log(JSON.stringify({ msg: "order confirmed", orderId: order.orderId, ...result }));
    } catch (error) {
      console.error(JSON.stringify({ msg: "order failed", orderId: order.orderId, error: String(error) }));
      // Only this message goes back to the queue, the others count as done
      batchItemFailures.push({ itemIdentifier: record.messageId });
    }
  }
  return { batchItemFailures };
}
Enter fullscreen mode Exit fullscreen mode

If an order fails for good, the handler reports only that message through batchItemFailures. The other messages in the batch count as done. How this partial batch response works in detail is explained in my article on SQS partial batch response.

serverless.yml

# serverless.yml
service: backstube-flour-orders
frameworkVersion: "4"

provider:
  name: aws
  runtime: nodejs22.x
  region: eu-central-1
  memorySize: 256
  logRetentionInDays: 14
  environment:
    MILL_API_URL: https://mill.example.com
    # Test the new SDK retry defaults before November 2026
    AWS_NEW_RETRIES_2026: "true"

functions:
  sendFlourOrders:
    handler: src/handler.handler
    timeout: 30
    events:
      - sqs:
          arn: !GetAtt FlourOrderQueue.Arn
          batchSize: 5
          functionResponseType: ReportBatchItemFailures

resources:
  Resources:
    FlourOrderQueue:
      Type: AWS::SQS::Queue
      Properties:
        # at least 6 x function timeout (30 s)
        VisibilityTimeout: 180
        RedrivePolicy:
          deadLetterTargetArn: !GetAtt FlourOrderDlq.Arn
          maxReceiveCount: 5
    FlourOrderDlq:
      Type: AWS::SQS::Queue
      Properties:
        MessageRetentionPeriod: 1209600
Enter fullscreen mode Exit fullscreen mode

Two values come straight from the Lambda documentation: the queue’s visibility timeout should be at least six times the function timeout, and maxReceiveCount should be at least 5. Deploy and test:

serverless deploy
aws sqs send-message \
  --queue-url "$(aws sqs get-queue-url --queue-name <name from the output> --query QueueUrl --output text)" \
  --message-body '{"orderId":"o-1001","bakeryId":"b-7","kilograms":25}'
serverless logs -f sendFlourOrders --tail
Enter fullscreen mode Exit fullscreen mode

With the example URL mill.example.com there is no real mill, of course. You will see errors in the log, and after five deliveries the message lands in the dead-letter queue (DLQ). For a real test, put your own endpoint into MILL_API_URL, for example a mock that randomly answers with 503.

Add up the layers

Retry layers: SQS with 5 deliveries and Lambda with 3 attempts add up to 15 calls to the mill API

Every layer that retries multiplies the calls of the layer below.

Asynchronous Lambda invocations via S3, SNS or EventBridge are a special case: Lambda retries on its own there and then quietly discards the event. I show how to catch such events with a failure queue in the article Lambda Destinations: Catch Failed Async Events.

This is the mistake I see most often in serverless architectures. The SQS queue delivers a message up to five times. The handler tries each delivery three times. In the worst case, that adds up to 15 calls per order. That is fine as long as it is a conscious decision. It becomes dangerous when the AWS SDK, an HTTP client and maybe an API client in front of it all bring their own retries. In the Amazon Builders’ Library, Brooker walks through an example: with five layers of three attempts each, the load on the bottom layer grows by a factor of 243.

My rule: short retries in the millisecond range where the call happens. Long retries over minutes are the queue’s job. All other layers do not retry, or only minimally. And remember that waiting in Lambda costs execution time. You can get a rough idea of how much with the Lambda cost calculator.

New AWS SDK retry defaults from November 2026

In May 2026, AWS announced that it will unify the retry defaults across its SDKs. Until November 2026 you can opt in, after that the new behavior becomes the default. The JavaScript SDK v3 needs at least version 3.1048.0. According to the tracking issue in the SDK repository, these values change in standard mode:

Setting (JS SDK v3) Before New
Base delay for transient errors 100 ms 50 ms
Base delay for throttling 100 ms 1,000 ms
Retry quota cost per retry 10 tokens 14 tokens
DynamoDB: base delay for transient errors 100 ms 25 ms
DynamoDB: max attempts 3 4
Other services: max attempts 3 3

The retry quota is a token bucket with 500 tokens per client. Each retry costs tokens, and a success refills some. Once the bucket is empty, the SDK returns errors right away instead of retrying. Making a retry after a transient error more expensive is intentional: during longer outages the SDK gives up sooner and leaves the service room to breathe. For throttling, however, it waits much longer before asking again.

How to test the new behavior now

In a test stage, set the environment variable AWS_NEW_RETRIES_2026=true, just like in the serverless.yml above. The SDK reads it when it loads. Keep an eye on DynamoDB in particular: because of the fourth attempt, a call can take longer than before when things go wrong. That matters if your function has tightly calculated timeouts.

Explicitly configured values take precedence. If the SDK call already sits inside your own retry() loop, keep the SDK retries small:

// src/clients.ts
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";

// Behind your own retry loop: keep SDK retries small
export const ddb = new DynamoDBClient({
  maxAttempts: 2,
  retryMode: "standard",
});
Enter fullscreen mode Exit fullscreen mode

Still, I would not advise switching SDK retries off across the board. For most calls to AWS services, the built-in retries with a token bucket beat anything you write yourself. Your own loop pays off mainly for third-party systems the SDK knows nothing about.

Common retry mistakes

Retrying everything. A catch without any distinction also retries validation errors and bugs. That wastes time and hides the actual error in the log.

Retrying writes without an idempotency key. Duplicate orders, duplicate emails, duplicate charges. If the third-party system offers no idempotency, a retry after a timeout is a gamble.

No timeout per attempt. Without AbortSignal.timeout(), a single attempt hangs until Lambda terminates the function. Instead of a second attempt, all you get is a timeout error.

Fixed wait times. await sleep(1000) in a loop is not backoff. All clients keep coming back in lockstep.

Endless retries during long outages. If a partner is down for an hour, even perfectly spread retries do not help. That is when a circuit breaker belongs in front, one that stops calling altogether after a threshold.

My take

Retries feel like stability, but first and foremost they amplify load. That is why I like the stance from the Builders’ Library: retry at exactly one point and limit that point deliberately. What I find interesting is that Brooker prefers token buckets over circuit breakers there, because circuit breakers introduce states that are hard to test. I only half disagree. For AWS services, the token bucket in the SDK is plenty. For a single slow third-party system that hangs for 25 seconds on every call, a breaker helps me more, because it does not wait in the first place.

The SDK change in November is no reason to panic, but it is a good occasion for a small inventory. Where does your system retry? How often in the worst case? And does the team know, or is it hidden in defaults nobody has ever read?

FAQ

What is exponential backoff with jitter?

A retry strategy: the maximum wait doubles with every attempt up to a cap, and the actual wait is picked at random below that limit. Struggling systems get breathing room, and clients do not all retry at the same time.

How many retries make sense?

For synchronous calls, usually two retries, so three attempts in total. More rarely pays off because the wait grows while the time budget shrinks. Longer retries over minutes are better handled by a queue.

Does the AWS SDK for JavaScript retry automatically?

Yes. In standard mode, SDK v3 makes three attempts with backoff, jitter and a retry quota. From November 2026, new defaults apply, including four attempts for DynamoDB. You can adjust this per client with maxAttempts and retryMode.

What is the difference between a retry and a circuit breaker?

A retry repeats a single call and helps with short glitches. A circuit breaker watches many calls and stops them entirely when a system is down for longer. They complement each other: the retries sit behind the breaker.

Your next step

Pick one call to a third-party system in your project and count every layer that retries: your own code, the HTTP client, the SDK, the queue, Lambda async retries. Multiply the numbers. If the result surprises you, you have found your next refactoring candidate. Then set AWS_NEW_RETRIES_2026=true in a test stage before November does it for you.

How many retry layers did you count in your system, and which one did you not know about before? Let me know in the comments.

How to apply backoff with jitter to webhooks for external partners, including a queue, signatures and a dead-letter queue, is shown in the article Webhook retries: reliable delivery with SQS and Lambda.

Sources

This post was created with AI assistance.


👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)