# SQS retries or Durable Functions?

> A job calls something flaky (a third-party API, an LLM, a slow Postgres). How do I retry it reliably, and when do I reach for Lambda durable functions instead of tuning SQS? SQS + Lambda for one-step jobs: it buffers, protects the downstream and retries, as long as three settings are right. Lambda durable functions (Watching) for multi-step jobs, where a failure in step 3 mustn't rerun steps 1 and 2: the retries live per step, in code. Both is the shape I'd pilot: SQS in front for back-pressure, a durable function behind it, finishing within 15 minutes or started asynchronously.

- A choice between tools from The Stack by Nicolas Dao (Cloudless Labs, Sydney): when to pick each, and when to run both
- Web page: https://cloudlesslabs.com/stack/guides/retries/. The whole stack in one file: https://cloudlesslabs.com/stack/brief.md
- Tools: [Amazon SQS](https://cloudlesslabs.com/stack/aws-sqs/index.md) (My default), [AWS Lambda](https://cloudlesslabs.com/stack/aws-lambda/index.md) (My default), [Lambda durable functions](https://cloudlesslabs.com/stack/aws-lambda-durable-functions/index.md) (Watching), [Amazon EventBridge](https://cloudlesslabs.com/stack/aws-eventbridge/index.md) (My default), [Cloudflare Durable Objects](https://cloudlesslabs.com/stack/cloudflare-durable-objects/index.md) (Use when it fits), [Cloudflare Queues](https://cloudlesslabs.com/stack/cloudflare-queues/index.md) (Skip), [AWS Step Functions](https://cloudlesslabs.com/stack/aws-step-functions/index.md) (Never), [Amazon DynamoDB](https://cloudlesslabs.com/stack/aws-dynamodb/index.md) (Never)

## When to pick which

Pick SQS + Lambda retries when:

- One step: call an API, write Postgres. A rerun of the whole handler is harmless.
- High volume in batches, with maximum concurrency protecting a fragile downstream.
- You want a dead-letter queue and a built-in redrive back to the source.

Pick Lambda durable functions when:

- Several steps call flaky services, and a failure shouldn't rerun the steps that already succeeded.
- Chained model calls: a failure at the last call mustn't pay again for the tokens of the first ones.
- Each step needs its own backoff, longer than a visibility timeout, without paying for compute while it waits.
- The job pauses for a person or an outside system, for hours or days.

Pick Lambda or EventBridge built-in retries when:

- One hop, fire and forget, idempotent: a notification, a cache refresh. Lambda's async retries or EventBridge's delivery retries are enough.
- Always with an on-failure destination or a dead-letter queue, or the event is dropped after the last attempt.

Both, when:

- SQS in front of a durable function: SQS absorbs spikes and caps concurrency, the event source mapping invokes the durable function through an alias, and each outside call is a step with its own retry strategy.
- When SQS triggers it directly, the whole execution, retry waits included, must finish within 15 minutes, so keep step backoffs short. For longer backoffs, a small Lambda starts the durable execution asynchronously, and failures go to the function's DLQ with an EventBridge alert.

Questions that change the answer:

- How many steps call something flaky? One is SQS + Lambda; several, where a rerun is expensive or wrong, is a durable function.
- How long must a retry wait? SQS waits a visibility timeout (up to 12 hours from the first receive); a durable function waits per step, up to a year when invoked asynchronously.
- Does every step tolerate running twice? Both options deliver at least once, so the answer decides your idempotency keys, not the product.
- Can your team learn replay's rules before production? Durable functions rerun code outside steps on every replay, and can only be switched on when the function is created.

## The short version

| | SQS + Lambda | Lambda durable function | Lambda async / EventBridge |
|---|---|---|---|
| My verdict | My default | Watching | Built into my defaults |
| What reruns on failure | The whole handler | Only the failed step | The whole handler |
| Retry delay | The visibility timeout, the same each time | Per step: exponential, jitter, a maximum | Lambda: 1 minute, then 2; EventBridge: exponential with jitter |
| Attempts | maxReceiveCount, then the DLQ | maxAttempts per step | Lambda 0–2; EventBridge 0–185 |
| Longest wait | 12 hours from the first receive | Up to 366 days (asynchronous); 15 minutes in all behind SQS | 6 hours of event age (Lambda); 24 hours (EventBridge) |
| Where the policy lives | Queue settings in Pulumi | In the handler's code | Function or rule settings |
| Back-pressure | Maximum concurrency, 2 to 1,000 per mapping | From SQS in front | None |
| After the last attempt | DLQ, with redrive to the source | DLQ on the function, EventBridge alert; no destinations | On-failure destination (Lambda); DLQ or dropped (EventBridge) |
| 1M four-step runs a month | ~$10, every failure reruns all four | ~$47 | — |

Price isn't the argument here: $10 against $47 a month is small next to a rerun that pays again for LLM tokens or charges a card twice. Fit is. For comparison, [Step Functions](https://cloudlesslabs.com/stack/aws-step-functions/index.md) runs the same retries from a JSON state machine and bills every retry as another state transition. Over-engineered: I've never seen the need in production.

## SQS + Lambda: my default, if three settings are right

For a one-step job, nothing beats it. The queue holds the work, Lambda takes it in batches, and maximum concurrency caps what reaches a small Postgres or a rate-limited API. The pain is that the retry policy is spread across queue settings that have to be exactly right, or messages land in the dead-letter queue:

- **Visibility timeout at least six times the function timeout**, plus the batching window. The margin is there so Lambda can retry when the function is throttled. And the visibility timeout *is* your retry delay: a long function timeout means a long wait before the next attempt.
- **ReportBatchItemFailures**, returning only the failed message IDs. If the function throws, the whole batch fails anyway, and so does a malformed response (an empty ID, a wrong key, an ID not in the batch): a typo silently retries everything. Powertools' batch utility writes the response for you.
- **maxReceiveCount set deliberately**, at least 5 as AWS recommends, and an alarm on the DLQ's depth.

The rest are traps. Every attempt waits the same visibility timeout; for backoff, set a longer visibility timeout on the failed message yourself (`ChangeMessageVisibility`), capped at 12 hours from its first receive. On a standard queue the DLQ keeps the original enqueue time, so give the DLQ a longer retention than the source (up to 14 days), or messages expire there early. Redrive back to the source is built in, but redriven messages get a new message ID, so the idempotency key must come from the body. And on a FIFO queue, a poison message blocks its whole message group until it reaches the DLQ.

## Lambda durable functions: retries per step, in code

Durable functions fix exactly the pain above. The job is ordinary sequential code; each risky call goes in `context.step()` with its own retry strategy (`createRetryStrategy`: maxAttempts, initialDelay, maxDelay, backoffRate, jitter, and which errors to retry). Lambda checkpoints every completed step. When step three fails, the function suspends, waits out the backoff without compute charges, and replays from the top, skipping steps one and two by reading their saved results. Each retry is one more durable operation, at $8.70 per million in Sydney, where it has run since December 2025.

Always pass an explicit strategy. AWS's documentation gives two different defaults, and its own advice is not to rely on them in production. An exception thrown outside a step isn't retried at all: the execution is marked failed.

It stays on my watch list because replay has rules plain Lambda doesn't. Code outside steps runs again on every replay, so `Date.now()`, random IDs and reads go inside a step. Steps run at least once per attempt, so outside APIs get an idempotency key, and a charge that must not repeat runs at most once per retry, with no retries. Renaming a step breaks executions in flight. Durable execution can only be switched on when the function is created. And a failed asynchronous execution isn't retried, and Lambda destinations aren't supported: put a DLQ on the function and an EventBridge rule on FAILED, STOPPED and TIMED_OUT. None is a deal-breaker; a team has to learn them first.

## Built-in retries, for one hop

Not every job needs a queue. Lambda invoked asynchronously retries a failing function twice by default (after 1 minute, then 2), configurable down to none, and keeps the event up to 6 hours. It can deliver duplicates even without errors. After the last attempt, an on-failure destination (SQS, SNS, Lambda, EventBridge or S3) receives the request, the response and the reason. EventBridge retries delivery to a rule target or a schedule for up to 24 hours, 185 attempts, with exponential backoff and jitter, then drops the event unless the target has a dead-letter queue. I put an SQS DLQ on every one.

That's enough for a single idempotent hop that can arrive late. It's not enough once you need more than three attempts on your own code's errors, longer backoff, or back-pressure on a fragile downstream: then SQS goes in front.

## Both, idempotency, and the edge

**SQS in front of a durable function** is how I'd pilot it. SQS absorbs spikes and protects the downstream; the event source mapping invokes the durable function through an alias; each outside call is a step. The catch: behind an SQS trigger, the whole execution, waits included, must finish within 15 minutes, so step backoffs stay in seconds. For minutes or hours of backoff, AWS's pattern is a small Lambda between them that starts the durable execution asynchronously, named after the message so a duplicate doesn't start it twice. Failed executions are then no longer SQS's job: the function's DLQ and the EventBridge alert catch them.

**Idempotency is the same job in every option**, because every one delivers at least once. Powertools' idempotency utility defaults to DynamoDB, which I don't use: I've never had a use case it fits better than Postgres with JSONB. A unique key on the business ID in Postgres, written with `ON CONFLICT DO NOTHING` in the same transaction as the work, does it with no library. Powertools also takes a custom persistence layer if you want its helpers on Postgres; it has no built-in one.

**At the edge**, a job that starts in a Worker has its own equivalents. Cloudflare Workflows (GA since April 2025) is the same idea as durable functions: per-step retries (5 by default, from 10 seconds, exponential), sleeps up to 365 days, and retries aren't billed as steps. The data and the compute sit on Cloudflare, not in the client's AWS account. A [Durable Object](https://cloudlesslabs.com/stack/cloudflare-durable-objects/index.md) alarm retries automatically when it throws, up to 6 times from 2 seconds, which suits one entity that retries until AWS acknowledges its result; alarms can fire more than once, so the handler checks the object's state first. When the work belongs in AWS, the Worker sends to SQS directly: [Cloudflare Queues](https://cloudlesslabs.com/stack/cloudflare-queues/index.md) is a Skip there.

Which queue, bus or topic carries the work in the first place is in [SQS, EventBridge or SNS?](https://cloudlesslabs.com/stack/guides/sqs-eventbridge-or-sns/index.md).

Sources, checked on 25 October 2026: [configuring an SQS event source](https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-configure.html), [SQS error handling and partial batches](https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-errorhandling.html), [SQS scaling and FIFO](https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-scaling.html), [SQS dead-letter queues](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html), [DLQ redrive](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-configure-dead-letter-queue-redrive.html), [SQS visibility timeout](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html), [SQS message quotas](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/quotas-messages.html), [Powertools idempotency](https://docs.aws.amazon.com/powertools/typescript/latest/features/idempotency/), [Lambda async error handling](https://docs.aws.amazon.com/lambda/latest/dg/invocation-async-error-handling.html), [Lambda async destinations](https://docs.aws.amazon.com/lambda/latest/dg/invocation-async-retain-records.html), [EventBridge retries](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-rule-retry-policy.html), [Lambda durable functions](https://docs.aws.amazon.com/lambda/latest/dg/durable-functions.html), [durable function retries](https://docs.aws.amazon.com/lambda/latest/dg/durable-execution-sdk-retries.html), [retry strategy reference](https://docs.aws.amazon.com/durable-execution/sdk-reference/error-handling/retries/), [durable functions with event source mappings](https://docs.aws.amazon.com/lambda/latest/dg/durable-invoking-esm.html), [DurableConfig](https://docs.aws.amazon.com/lambda/latest/api/API_DurableConfig.html), [durable functions in Sydney](https://aws.amazon.com/about-aws/whats-new/2025/12/lambda-durable-functions-14-additional-regions/), [Step Functions pricing](https://aws.amazon.com/step-functions/pricing/), [Cloudflare Workflows retries](https://developers.cloudflare.com/workflows/build/sleeping-and-retrying/), [Workflows pricing](https://developers.cloudflare.com/workflows/reference/pricing/), [Workflows GA](https://developers.cloudflare.com/changelog/2025-04-07-workflows-ga), [Durable Objects alarms](https://developers.cloudflare.com/durable-objects/api/alarms/).

## Related

- [Amazon SQS](https://cloudlesslabs.com/stack/aws-sqs/index.md): the full entry (verdict, pricing, gotchas)
- [AWS Lambda](https://cloudlesslabs.com/stack/aws-lambda/index.md): the full entry (verdict, pricing, gotchas)
- [Lambda durable functions](https://cloudlesslabs.com/stack/aws-lambda-durable-functions/index.md): the full entry (verdict, pricing, gotchas)
- [Amazon EventBridge](https://cloudlesslabs.com/stack/aws-eventbridge/index.md): the full entry (verdict, pricing, gotchas)
- [Cloudflare Durable Objects](https://cloudlesslabs.com/stack/cloudflare-durable-objects/index.md): the full entry (verdict, pricing, gotchas)
- [Cloudflare Queues](https://cloudlesslabs.com/stack/cloudflare-queues/index.md): the full entry (verdict, pricing, gotchas)
- [AWS Step Functions](https://cloudlesslabs.com/stack/aws-step-functions/index.md): the full entry (verdict, pricing, gotchas)
- [Amazon DynamoDB](https://cloudlesslabs.com/stack/aws-dynamodb/index.md): the full entry (verdict, pricing, gotchas)
- [Every choice between tools](https://cloudlesslabs.com/stack/choices/index.md)
- [Map of every Markdown file on cloudlesslabs.com, with what each one answers](https://ask.cloudlesslabs.com)
