Retries, Backoff and Timeouts
Exponential backoff with full jitter, the SDK's maxAttempts and retryMode settings, and which calls are safe to retry.
new S3Client({ maxAttempts: 5, retryStrategy: new ConfiguredRetryStrategy(5, (attempt) => 100 * 2 ** attempt) })Network calls fail for two different reasons. A transient failure is temporary — a reset connection, an HTTP 503. A throttling failure means the service deliberately rejected you for sending too fast. Both are worth trying again. A validation error or a permissions error is not.
What the SDK already does
The AWS SDK v3 retries on its own. The default retryMode is standard and the default maxAttempts is 3, which means one initial request and up to two retries. Set maxAttempts on the client, or the AWS_MAX_ATTEMPTS and AWS_RETRY_MODE environment variables, or max_attempts and retry_mode in ~/.aws/config. DynamoDB clients default to 4 attempts.
Backoff with full jitter
Backoff means waiting longer before each retry. Jitter means randomising that wait. The standard formula is delay = random(0,1) * min(20000ms, base * 2^retry), with base of 50ms for transient errors and 1000ms for throttling. Jitter matters because without it a thousand clients that failed together retry together, and the burst keeps the service down. Standard mode also keeps a token budget: sustained failures exhaust it and calls fail fast instead of waiting through retries. The updated 2026 behaviour described here is opt-in with AWS_NEW_RETRIES_2026=true.
Timeouts, and what is safe
A retry needs a deadline, or a hung call never reaches it. Set requestTimeout on the client, and use a per-call signal for one request. These two are the same thing, short and long:
AbortSignal.timeout(2000)
const c = new AbortController();
setTimeout(() => c.abort(), 2000); // then pass c.signalOnly retry work that is safe to repeat. Reading is. Sending a queue message is not, unless the consumer is idempotent.
Example
When to use it
- Keep a background export running through a few minutes of throttling without hand-written retry code.
- Stop a slow AWS call from holding an HTTP request open past its own deadline.
- Give a user-facing call one quick attempt and a batch job ten patient ones, on the same account.
- Decide which calls in a worker may be retried after a timeout and which must not.
More examples
Two clients, two retry budgets
One account usually needs two retry policies, and the client is where they live. ConfiguredRetryStrategy takes the attempt count and a function returning the delay in milliseconds, which is the hook for a custom curve. Note the precedence: pass both maxAttempts and retryStrategy and the strategy wins, which quietly makes maxAttempts look ignored.
import { S3Client } from "@aws-sdk/client-s3";
import { ConfiguredRetryStrategy } from "@smithy/util-retry";
// User-facing: a person is waiting. Try once more at most, and give up fast.
export const fastS3 = new S3Client({
maxAttempts: 2,
// Shorthand for the request handler; the SDK builds NodeHttpHandler for you.
requestHandler: { connectionTimeout: 1000, requestTimeout: 2000 },
});
// Background: nobody is waiting, so be patient. A retryStrategy overrides
// maxAttempts, because it is the more specific setting.
export const patientS3 = new S3Client({
retryStrategy: new ConfiguredRetryStrategy(
6, // total attempts, including the first
(attempt) => 100 * 2 ** attempt // backoff in ms for this attempt
),
requestHandler: { requestTimeout: 30_000 },
});A deadline the retries live inside
Per-attempt timeouts and attempt counts multiply, so a client that looks patient can hold an HTTP handler open for half a minute. An abort signal passed to send puts a ceiling on the whole thing. Returning null for a timeout here is a deliberate choice: the caller renders a page without the profile instead of failing the request.
import { GetObjectCommand } from "@aws-sdk/client-s3";
// Without an overall deadline, maxAttempts 5 can add up to far longer than
// the request budget. One signal caps the whole sequence of attempts.
export async function fetchProfile(s3, key) {
const signal = AbortSignal.timeout(3000); // Node.js 17.3.0 and later
try {
const out = await s3.send(
new GetObjectCommand({ Bucket: "profiles", Key: key }),
{ abortSignal: signal }
);
return JSON.parse(await out.Body.transformToString());
} catch (err) {
// An aborted call surfaces as an AbortError rather than hanging.
if (err.name === "AbortError" || err.name === "TimeoutError") return null;
throw err;
}
}Retrying only what is safe
The question is never whether a call failed but whether repeating it is harmless. A PUT to a fixed key is naturally idempotent. A queue send is not, because the server may have accepted the message before the connection broke. The fix is not fewer retries, which only converts duplicate work into lost work, but a consumer that recognises a key it has already handled.
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { SendMessageCommand } from "@aws-sdk/client-sqs";
// SAFE. Writing the same bytes to the same key twice leaves one object.
// The operation is idempotent, so a retry cannot duplicate anything.
await s3.send(new PutObjectCommand({
Bucket: "invoices", Key: `invoice-${invoiceId}.pdf`, Body: pdf,
}));
// NOT SAFE on its own. If the response is lost after the server accepted it,
// a retry enqueues the work a second time and the consumer runs it twice.
await sqs.send(new SendMessageCommand({
QueueUrl,
MessageBody: JSON.stringify({ invoiceId }),
}));
// Made safe by the consumer: it keys off invoiceId and ignores a repeat.
// Idempotency belongs on the receiving side, not in the retry policy.
Discussion