Lambda Destinations Not Working? The 5 Causes (Sync Calls, Role Permissions, FIFO, Aliases, Retries), Destinations vs DLQ, EventBridge Rules, CDK, Terraform and SAM
Lambda destinations send a record of each invocation to an SQS queue, SNS topic, another Lambda function, an EventBridge event bus, or (on failure only) an S3 bucket. When they "do not work," the cause is almost always one of five things, in this order. First, the function was invoked synchronously: destinations only fire for asynchronous invocations (S3 and SNS events, EventBridge rules, aws lambda invoke --invocation-type Event), so the console's Test button, API Gateway and an ordinary aws lambda invoke never trigger them. Second, the execution role lacks the permission for the destination (sqs:SendMessage, sns:Publish, lambda:InvokeFunction, events:PutEvents, or s3:PutObject plus s3:ListBucket). Third, the destination is a FIFO queue or topic, which is not supported. Fourth, you configured it on one version or alias and invoked another. Fifth, you did not wait: an on-failure record arrives only after all retries, about three minutes later by default, and up to six hours for throttled events. Below: how to test each one, the DestinationDeliveryFailures metric that tells you which, destinations vs dead-letter queues, event source mappings, and the setup in the console, CLI, CDK and Terraform.
Jake's computer shop sends a repair-ready text message to customers from a Lambda function that runs when a job is marked done. Sometimes the text provider is down, and Ethan wanted those failures to land in a queue so nobody was forgotten. He added an on-failure destination, pressed Test with a payload that was meant to fail, watched the error appear, and checked the queue. Empty. He pressed Test five more times. Still empty. The setup was not broken; the test was. This page is what he learned in the next hour.
New to Lambda? It runs your code when something happens, without a server to manage. Our Lambda explainer covers the basics, and the cards below cover the services a destination can send to.
What a Lambda destination is
A destination is a place Lambda sends a short report about an invocation, after it finishes. You choose a condition and a target:
- On success: a record is sent when the function handles an asynchronous event without error. Useful for chaining work, such as "after the image is resized, tell the next service."
- On failure: a record is sent when an event fails every attempt, or expires before it could be processed. This is the one most people want: a safety net so failed events are not lost.
The record is a JSON document with the original event, the response or error, and details such as how many times Lambda tried. You can send it to five kinds of target:
| Destination | On success | On failure | Good for |
|---|---|---|---|
| Amazon SQS standard queue | Yes | Yes | Holding failed events to retry later |
| Amazon SNS standard topic | Yes | Yes | Alerting people (email, SMS) or fanning out |
| Another Lambda function | Yes | Yes | Chaining steps, custom error handling |
| Amazon EventBridge event bus | Yes | Yes | Routing results to many consumers by rules |
| Amazon S3 bucket | No | Yes | Keeping full records of large failed events |
Destinations can be set on the function, on a published version, or on an alias, so a production alias can have a different safety net from a test version.
Jake: "So when the text fails, the queue gets a note saying who we didn't text?"
Ethan: "The whole original event, plus the error. Someone can retry it later, or a second function can."
Cause 1: the function was called synchronously
This is the cause behind most "destinations not working" questions, and it is not an error at all. Lambda can be invoked in two ways:
- Synchronously (request-response): the caller waits for the answer. The console's Test button, API Gateway, Application Load Balancers, function URLs, and
aws lambda invokewith no options all invoke this way. The caller gets the result or the error directly, so Lambda has nothing to report afterward. Destinations never fire. - Asynchronously (event): the caller hands the event to Lambda's internal queue and moves on. Amazon S3 event notifications, Amazon SNS, EventBridge rules, CloudWatch Logs subscriptions and
aws lambda invoke --invocation-type Eventwork this way. Lambda handles retries itself, and when it is done, it sends the record to your destination.
So the test that matters is an asynchronous one. With the AWS CLI version 2:
aws lambda invoke --function-name repair-text \
--invocation-type Event \
--cli-binary-format raw-in-base64-out \
--payload '{"fail": true}' out.json
The command returns at once with "StatusCode": 202, which means "accepted," not "succeeded." The result goes to your destination later. On Windows PowerShell, quoting JSON on the command line is awkward; save the payload as event.json and use --payload fileb://event.json instead.
Event source mappings, the triggers for SQS, Kinesis, DynamoDB Streams and Kafka, are a third kind of invocation with their own failure handling. They do not use asynchronous destinations either; the event source mapping section covers them.
Jake: "So the Test button has been lying to us?"
Ethan: "It's been telling the truth to the person pressing it. Destinations are for when nobody is waiting for the answer."
Calling a function asynchronously from code, API Gateway and Step Functions
If your own code or another AWS service calls the function and you want destinations to work, the call itself has to be asynchronous. Here is how to do that from the places people most often call Lambda.
Python (boto3):
import boto3
lam = boto3.client("lambda")
resp = lam.invoke(
FunctionName="repair-text",
InvocationType="Event",
Payload=b'{"jobId": "R-1042", "phone": "+15550100"}',
)
print(resp["StatusCode"]) # 202 means queued for async processing
JavaScript (AWS SDK for JavaScript v3):
import { LambdaClient, InvokeCommand } from "@aws-sdk/client-lambda";
const client = new LambdaClient({});
const out = await client.send(new InvokeCommand({
FunctionName: "repair-text",
InvocationType: "Event",
Payload: Buffer.from(JSON.stringify({ jobId: "R-1042" })),
}));
console.log(out.StatusCode); // 202
Without InvocationType, both SDKs default to RequestResponse, the synchronous mode, and destinations stay silent. The same default applies to aws lambda invoke. A 202 status code tells you the event was queued; a 200 tells you the call was synchronous and no destination will ever see it.
API Gateway REST API: a Lambda proxy integration always waits for the function. To hand the request off and return at once, use a non-proxy (custom) Lambda integration and add an integration request header named X-Amz-Invocation-Type with the mapped value 'Event'. The single quotes are part of the value; they make it a fixed string rather than a reference to the incoming request. The client then gets an immediate response, and the function's asynchronous settings, retries and destinations apply. HTTP APIs have no such switch, so if you need it there, have the API drop the request onto a queue or an EventBridge bus instead.
Step Functions: in a Lambda Invoke task (arn:aws:states:::lambda:invoke), set "InvocationType": "Event" in the task's parameters (or arguments, in JSONata workflows). The state then finishes as soon as Lambda accepts the event, and the result goes to the destination instead of back to the workflow. Most workflows want the result, so this is rare; Step Functions has its own retry and catch rules, which our Step Functions TaskFailed guide walks through.
Jake: "And the button in our shop app that marks a repair done?"
Ethan: "It calls the function from Python. Adding InvocationType Event is one line, and the button stops waiting on the text provider too."
Cause 2: the execution role cannot send to the destination
Lambda sends the record using your function's execution role, not your own permissions and not a service-linked role. If that role is missing the right permission, the record is not delivered, and nothing tells the function.
| Destination | Permission the execution role needs |
|---|---|
| SQS queue | sqs:SendMessage |
| SNS topic | sns:Publish |
| Lambda function | lambda:InvokeFunction |
| EventBridge event bus | events:PutEvents |
| S3 bucket (on failure) | s3:PutObject and s3:ListBucket, plus kms:GenerateDataKey if the bucket uses a KMS key |
Here is a minimal policy for an SQS on-failure destination. Add it to the execution role (IAM → Roles → your function's role → Add permissions → Create inline policy):
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "sqs:SendMessage",
"Resource": "arn:aws:sqs:us-east-1:123456789012:repair-text-failures"
}]
}
When you add a destination in the console, Lambda may offer to add the permission for you. If you built the function with infrastructure as code, check the role yourself; it is the most common gap.
If the role lacks the permission at the moment you save, Lambda can refuse the configuration with an InvalidParameterValueException that names the missing action. That is the easy case, because you see it straight away. The hard case is a permission that disappears later: someone tightens the role, a permissions boundary or a service control policy blocks the action, or the queue is recreated with a stricter policy. Saving worked, so nobody looks at the role again, and deliveries fail quietly. The metric in the next section is how you catch that.
Encrypted queues and topics. If the queue or topic uses a customer managed KMS key, the execution role also needs kms:GenerateDataKey and kms:Decrypt on that key, and the key policy must let the role use it:
{
"Effect": "Allow",
"Action": ["kms:GenerateDataKey", "kms:Decrypt"],
"Resource": "arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890ab"
}
Queue and topic policies. If the queue or topic has its own access policy that limits who may send, it must allow the function's role. If it lives in another AWS account, that resource policy is required, not optional: the role's permission says "I may send," and the queue's policy must also say "you may send to me." Both sides have to agree.
Switching targets. When you replace a destination, for example moving from a FIFO queue to a standard one, the role's policy often still names the old ARN. Update the Resource line along with the destination.
How to tell which cause it is: DestinationDeliveryFailures
Lambda publishes a CloudWatch metric for exactly this: DestinationDeliveryFailures. It counts the times Lambda tried to send a record to a destination and could not. Open CloudWatch → Metrics → Lambda → By Function Name, pick your function, and look at it alongside AsyncEventsReceived and Errors.
| What you see | What it means |
|---|---|
| No async events at all | The function is being invoked synchronously (cause 1), or not at all |
| Errors, but no delivery failures and no record yet | Retries are still running (cause 5), or the destination is on a different version or alias (cause 4) |
| DestinationDeliveryFailures above zero | Lambda tried and failed: permissions (cause 2), a FIFO target (cause 3), a deleted target, or a record too large |
| No errors, and the failure destination is empty | The function did not fail: it caught the exception and returned normally |
That last row catches many people. If your code wraps everything in try/except (or try/catch) and returns an error message instead of raising it, Lambda sees a successful invocation, and the on-success destination gets the record. For the on-failure destination to fire, the function must actually fail: raise or throw the exception, or let it propagate.
def handler(event, context):
try:
send_text(event["phone"], event["message"])
except ProviderError as e:
print(f"text failed for {event['jobId']}: {e}")
raise # without this line, Lambda counts the call as a success
return {"sent": event["jobId"]}
Two more async metrics help. AsyncEventsReceived counts the events Lambda queued for the function, which confirms the asynchronous path is in use at all. AsyncEventsDropped counts events that never ran successfully because they used up their retries or their maximum age; when a failure destination is configured, those are the events that should appear in it.
The safety net needs its own alarm, or you find out about a broken destination only when you go looking for a record that is not there. This CLI command alarms on the first delivery failure in any five-minute window:
aws cloudwatch put-metric-alarm --alarm-name repair-text-destination-failures \ --namespace AWS/Lambda --metric-name DestinationDeliveryFailures \ --dimensions Name=FunctionName,Value=repair-text \ --statistic Sum --period 300 --evaluation-periods 1 \ --threshold 0 --comparison-operator GreaterThanThreshold \ --treat-missing-data notBreaching \ --alarm-actions arn:aws:sns:us-east-1:123456789012:ops-alerts
The --treat-missing-data notBreaching part matters: on a quiet day the metric has no data at all, and without it the alarm sits in INSUFFICIENT_DATA. Our CloudWatch INSUFFICIENT_DATA guide explains the four missing-data choices.
Cause 3: a FIFO queue or FIFO topic
Destinations accept standard SQS queues and standard SNS topics only. If you point a destination at a .fifo queue or topic, the configuration can save, but every delivery fails and shows up in DestinationDeliveryFailures. The same rule applies to dead-letter queues.
The name tells you which kind you have: FIFO queues and topics always end in .fifo, and a queue cannot be changed from one type to the other after it is created. To fix it:
- Create a standard queue (or topic), for example
repair-text-failures. - Update the destination to the new ARN.
- Update the execution role's policy so its
Resourcenames the new ARN, not the.fifoone. - Run one asynchronous test and check that
DestinationDeliveryFailuresstays at zero.
If you need ordered processing of failures, there are two clean routes. Send them to a standard queue first and move them into a FIFO queue with a small function. Or use an EventBridge bus as the destination, with a rule whose target is the FIFO queue; EventBridge lets you set the message group ID on an SQS FIFO target. Either way, the destination itself stays standard.
Jake: "We picked FIFO because it sounded more careful."
Ethan: "It is, for orders. For a list of texts that failed, standard is fine: we resend them all anyway."
Cause 4: configured on one version, invoked on another
Destinations and retry settings belong to a specific function, version or alias. If you set the destination on $LATEST but your S3 trigger invokes the prod alias (or the other way round), the invocations that happen never see it.
Check what the trigger actually calls. In the trigger's configuration, the function ARN ends with the alias or version when there is one, for example ...:function:repair-text:prod. Then list the configuration for each qualifier:
aws lambda list-function-event-invoke-configs --function-name repair-text
Each entry shows the function ARN it applies to, with its retry settings and destinations. Make sure one matches the ARN your trigger uses. Note the contrast with dead-letter queues, which can only be set at the function level and are shared by all versions.
Cause 5: the record has not arrived yet (retries and event age)
An on-failure record is sent only when an event has used up every attempt, or has waited too long. By default:
- Function errors (your code raised an error, or the function timed out): Lambda tries twice more, waiting about one minute before the second attempt and two minutes before the third. So the failure record arrives roughly three minutes after the first try.
- Throttling and system errors (429 and 500-series): Lambda puts the event back in its queue and keeps retrying, with waits that grow from one second up to five minutes, for up to six hours by default.
You control both limits per function, version or alias: maximum retry attempts from 0 to 2, and maximum event age from 60 seconds to 6 hours (21,600 seconds). For testing, set retries to 0 and the record arrives right after the first failure:
aws lambda update-function-event-invoke-config --function-name repair-text \ --maximum-retry-attempts 0 --maximum-event-age-in-seconds 3600
Set retries back afterward if you want them in production. For something like a text message, one or two retries are sensible: the provider is often back within minutes.
One more case sends events straight to the failure destination: if you set the function's reserved concurrency to zero, Lambda stops running it and sends new asynchronous events directly to the on-failure destination or dead-letter queue, with no retries. That is handy for pausing a function during an incident, but it surprises people who set it and forget.
Jake: "We pressed Test six times in two minutes and gave up."
Ethan: "Even with a real async call, the first record would only have arrived after the retries, three minutes later. Set retries to zero while we test."
What the invocation record looks like
Here is the record Lambda sends for an event that failed three attempts. The requestPayload is your original event, which is what you need to retry it:
{
"version": "1.0",
"timestamp": "2026-10-19T14:16:05.568Z",
"requestContext": {
"requestId": "e4b46cbf-b738-4f6e-8880-a18cdf61200e",
"functionArn": "arn:aws:lambda:us-east-1:123456789012:function:repair-text:$LATEST",
"condition": "RetriesExhausted",
"approximateInvokeCount": 3
},
"requestPayload": { "jobId": "R-1042", "phone": "+15550100", "message": "Your laptop is ready" },
"responseContext": { "statusCode": 200, "executedVersion": "$LATEST", "functionError": "Unhandled" },
"responsePayload": { "errorMessage": "Text provider returned 503", "errorType": "ProviderUnavailable" }
}
The fields worth reading first:
condition: why the record was sent.RetriesExhaustedmeans every attempt failed;EventAgeExceededmeans the event waited longer than the maximum event age, often because of throttling;Successappears on on-success records.approximateInvokeCount: how many times Lambda ran the function for this event. A count of 1 on a failure record usually means retries are set to 0.executedVersion: which version actually ran, which settles cause 4 at a glance.responsePayload: the error your code raised, or the value it returned on success.
The condition is also handy for filtering: an alert for EventAgeExceeded points at throttling or concurrency limits, while RetriesExhausted points at your code or the service it calls. How the record arrives depends on the destination: as the message body in SQS and SNS, as the event payload to a Lambda function, as the detail of an EventBridge event (with source lambda and detail-type Lambda Function Invocation Result - Failure or - Success), and as a JSON file in S3 under aws/lambda/async/<function-name>/YYYY/MM/DD/.
EventBridge destination: the rule that catches the record
With an event bus as the destination, Lambda puts one event on that bus per record, and that is all it does. Nothing else happens until a rule on the same bus matches the event and sends it to a target. Two mistakes cause most silent EventBridge destinations: a rule written on the default bus while the destination is a custom bus (or the other way round), and a pattern that does not match the event Lambda actually sends.
Here is a pattern that catches failures from one function:
{
"source": ["lambda"],
"detail-type": ["Lambda Function Invocation Result - Failure"],
"detail": {
"requestContext": {
"functionArn": [{ "prefix": "arn:aws:lambda:us-east-1:123456789012:function:repair-text" }]
}
}
}
The prefix match covers $LATEST, versions and aliases, because the ARN in the record ends with the qualifier. Remove the detail block to catch failures from every function that sends to this bus, or change the detail-type to - Success to chain work on success. The rule's target can be anything EventBridge supports: an SQS queue (including FIFO, with a message group ID), a Step Functions state machine, another function, or an API destination that calls a webhook.
The rule's target needs permission too, separate from the function's role. For an SQS target, the queue policy must allow EventBridge to send to it; the console adds that when you create the rule there, but templates often miss it. A pattern that never matches looks exactly like a broken destination, so test the pattern against a real record first. Our EventBridge rule guide shows how to do that.
Get an email when a Lambda function fails
The simplest alert is an SNS topic as the on-failure destination, with your email address subscribed to it:
- Create a standard SNS topic, for example
repair-text-alerts. - Add an email subscription with your address.
- Open the email from AWS Notifications and choose Confirm subscription. Until you do, the subscription shows Pending confirmation and nothing is sent. Many reports of a broken SNS destination turn out to be this step.
- Give the function's role
sns:Publishon the topic, and set the topic as the on-failure destination. - Run one asynchronous test with retries at 0, and check your inbox and spam folder.
The email body is the raw JSON record: readable, but not pretty. If you want a tidy message such as "Job R-1042: text not sent, provider down," use a small function as the destination instead and have it publish a formatted message to the topic, or post to a chat webhook. That is the next section.
A Lambda function as the destination
Pointing a destination at a second function is the most flexible choice. The second function receives the invocation record as its event and can do anything with it: save a row for the morning review, try a backup text provider, or open a ticket. Three things to keep in mind:
- The first function's role needs
lambda:InvokeFunctionon the second function's ARN, including the alias or version if you target one. - The second function reads the original event from
requestPayloadand the error fromresponsePayload, not from the top level of the event. - Give the second function its own failure handling, pointed somewhere other than the first function. A failure handler that sends its own failures back to the original function creates a loop.
def handler(event, context):
job = event["requestPayload"]
error = (event.get("responsePayload") or {}).get("errorMessage", "unknown error")
tries = event["requestContext"]["approximateInvokeCount"]
print(f"Job {job['jobId']} failed after {tries} tries: {error}")
# save for the morning review, or try a backup provider here
return {"logged": job["jobId"]}
Ethan's version wrote each failed job to a small table, and the shop's morning checklist starts with "anyone not texted?" It took ten lines.
Large events: the SNS size limit and S3
An asynchronous event can be up to 1 MB, and the invocation record adds metadata on top. An SNS message can be at most 256 KB, so a large event can produce a record too big for an SNS destination, and that delivery fails. If your events can be large, use an SQS queue (which allows larger messages) or an S3 bucket as the on-failure destination. S3 keeps the full record as a file, which is also handy for investigating failures later.
If you use S3, keep the destination bucket in your own account and do not delete it while it is still configured; someone else could create a bucket with the same name and receive your failed events. Limiting the role's s3:PutObject permission to buckets in your own account (with the s3:ResourceAccount condition) closes that gap.
Destinations vs dead-letter queues
Before destinations existed, the way to catch failed asynchronous events was a dead-letter queue (DLQ) on the function. Both still work, and they are easy to confuse.
| On-failure destination | Dead-letter queue | |
|---|---|---|
| What is sent | The event plus the response, error and invoke count | The event only, with error code and message as attributes |
| Targets | SQS, SNS, Lambda, EventBridge, S3 | SQS or SNS (standard only) |
| Set on | Function, version or alias | Function only |
| Success records too | Yes (on-success destination) | No |
| Delivery problem metric | DestinationDeliveryFailures | DeadLetterErrors |
For new work, use an on-failure destination: it carries more information and works per alias. Keep an existing DLQ if things already depend on it. And do not confuse either with the dead-letter queue on an SQS queue itself, which is a different feature for messages that consumers fail to process; our SQS dead-letter queue guide covers that one.
SQS, Kinesis, DynamoDB Streams and Kafka triggers
When Lambda reads from a queue or stream through an event source mapping, asynchronous destinations do not apply, because these are not asynchronous invocations. Each source has its own way to catch failures:
- Amazon SQS trigger: configure a dead-letter queue (a redrive policy) on the source queue. Messages that fail processing the set number of times move there. Setting a destination or DLQ on the function does nothing for these messages.
- Kinesis and DynamoDB Streams: the event source mapping has its own on-failure destination (SQS, SNS or S3), plus settings for maximum retries, maximum record age and splitting a failing batch. Configure it on the trigger, not on the function's asynchronous settings.
- Apache Kafka (Amazon MSK or self-managed): the event source mapping also supports an on-failure destination.
If you added an on-failure destination to a function that is triggered by SQS and wondered why it never fills, this is why. The fix is on the queue.
Set up a destination: console and CLI
In the console:
- Open your function in the Lambda console.
- In the function overview, choose Add destination.
- For Source, choose Asynchronous invocation.
- For Condition, choose On failure (or On success).
- Choose the destination type and the resource, then Save.
- Under Configuration → Asynchronous invocation, check the retry attempts and maximum event age.
With the AWS CLI:
aws lambda update-function-event-invoke-config --function-name repair-text \
--destination-config '{"OnFailure":{"Destination":"arn:aws:sqs:us-east-1:123456789012:repair-text-failures"}}'
If the function has no asynchronous configuration yet, use put-function-event-invoke-config with the same options; update- changes an existing one. Add --qualifier prod to set it on an alias. On Windows PowerShell, put the JSON in a file and pass --destination-config file://dest.json.
With Python (boto3):
import boto3
lam = boto3.client("lambda")
lam.put_function_event_invoke_config(
FunctionName="repair-text",
MaximumRetryAttempts=2,
MaximumEventAgeInSeconds=3600,
DestinationConfig={
"OnFailure": {"Destination": "arn:aws:sqs:us-east-1:123456789012:repair-text-failures"}
},
)
print(lam.get_function_event_invoke_config(FunctionName="repair-text"))
One difference matters here. put_function_event_invoke_config replaces the whole configuration, so any setting you leave out is removed; update_function_event_invoke_config changes only what you pass. A script that "just adds the success destination" with put can quietly delete the failure destination you set last week. The same applies to the CLI commands. To see what is saved right now, run aws lambda get-function-event-invoke-config --function-name repair-text, adding --qualifier for an alias.
Set up a destination with the AWS CDK, Terraform, SAM and CloudFormation
AWS CDK (TypeScript): the function's props take onFailure, onSuccess, retryAttempts and maxEventAge, and the destination classes come from aws-cdk-lib/aws-lambda-destinations. The CDK also adds the permission to the execution role for you.
import { Duration } from "aws-cdk-lib";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as sqs from "aws-cdk-lib/aws-sqs";
import * as destinations from "aws-cdk-lib/aws-lambda-destinations";
const failures = new sqs.Queue(this, "RepairTextFailures");
new lambda.Function(this, "RepairText", {
runtime: lambda.Runtime.NODEJS_22_X,
handler: "index.handler",
code: lambda.Code.fromAsset("lambda"),
onFailure: new destinations.SqsDestination(failures),
retryAttempts: 2,
maxEventAge: Duration.hours(1),
});
Terraform: destinations live in a separate resource, aws_lambda_function_event_invoke_config. Remember to add the SQS permission to the role yourself.
resource "aws_lambda_function_event_invoke_config" "repair_text" {
function_name = aws_lambda_function.repair_text.function_name
maximum_retry_attempts = 2
maximum_event_age_in_seconds = 3600
destination_config {
on_failure {
destination = aws_sqs_queue.repair_text_failures.arn
}
}
}
resource "aws_iam_role_policy" "repair_text_dest" {
role = aws_iam_role.repair_text.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sqs:SendMessage"
Resource = aws_sqs_queue.repair_text_failures.arn
}]
})
}
To set it on an alias in Terraform, add qualifier = aws_lambda_alias.prod.name to the event invoke config.
AWS SAM: the function takes an EventInvokeConfig property. When you set Type, SAM adds the permission to the function's role, and for SQS or SNS it can create the queue or topic for you if you leave out Destination.
RepairText:
Type: AWS::Serverless::Function
Properties:
Runtime: python3.13
Handler: app.handler
CodeUri: src/
EventInvokeConfig:
MaximumRetryAttempts: 2
MaximumEventAgeInSeconds: 3600
DestinationConfig:
OnFailure:
Type: SQS
Destination: !GetAtt RepairTextFailures.Arn
Plain CloudFormation: use an AWS::Lambda::EventInvokeConfig resource. Its Qualifier property is required, which surprises people coming from the CLI: use $LATEST for the unpublished function, or an alias name. Add the sqs:SendMessage statement to the role yourself.
RepairTextInvokeConfig:
Type: AWS::Lambda::EventInvokeConfig
Properties:
FunctionName: !Ref RepairText
Qualifier: $LATEST
MaximumRetryAttempts: 2
MaximumEventAgeInSeconds: 3600
DestinationConfig:
OnFailure:
Destination: !GetAtt RepairTextFailures.Arn
Whichever tool you use, the alias rule from cause 4 still applies: the configuration belongs to the qualifier you name, and the trigger has to invoke that same qualifier. With the CDK, Terraform and SAM, the same tool also usually defines the trigger, so check both in one place.
Testing Lambda destinations locally
People looking into destinations often ask how to test Lambda locally, so here is how the two fit. The AWS SAM CLI's sam local invoke runs your function code in a container on your machine. That is excellent for testing the code itself, but it is a single, direct call: there is no asynchronous queue, no retries, no maximum event age and no destinations. A local run can tell you whether the function raises the error it should; it cannot tell you whether the record reaches the queue.
A routine that covers both:
- Locally, check that the function fails the way you expect:
sam local invoke RepairText -e events/fail.jsonshould end with an error, not a normal return. - In a development account or stack, set retries to 0 and run one
--invocation-type Eventtest. - Read the destination:
aws sqs receive-message --queue-url https://sqs.us-east-1.amazonaws.com/123456789012/repair-text-failures, or the email, or the S3 folder. - Check that
DestinationDeliveryFailuresstays at zero, then set retries back.
Tools that emulate AWS services on your machine may support parts of this flow, but their behavior can differ from the real service. Do the final check in a real account; one asynchronous test costs a fraction of a cent.
Reprocessing the failed events
A failure queue is only useful if someone empties it. Three common patterns:
- A retry function: a small Lambda function reads the failure queue (as its SQS trigger), takes
requestPayloadfrom each record and calls the original function again, asynchronously, once the outside problem is fixed. - An alert: an SNS destination, or a CloudWatch alarm on the failure queue's
ApproximateNumberOfMessagesVisible, tells a person that something needs attention. - A manual review: for rare, important failures, a person reads the record and decides. For Jake's shop, that meant a daily look at any customer who had not been texted.
Make sure the original function can safely run twice for the same event (idempotency). Lambda's own retries and your reprocessing can both deliver the same event, and asynchronous invocation can occasionally deliver an event more than once even without errors.
Stop or pause a function that keeps failing
Sometimes the right move is to stop the function while you fix the cause, for example when a bad deploy fails every event and the failure queue fills up faster than anyone can read it. The quickest way to stop a Lambda function without deleting anything is to set its reserved concurrency to 0. In the console that is the Throttle button on the function page; from the CLI:
aws lambda put-function-concurrency --function-name repair-text --reserved-concurrent-executions 0 # when the fix is deployed, remove the limit again: aws lambda delete-function-concurrency --function-name repair-text
What happens to asynchronous events while the function is stopped is where destinations earn their keep. Reserved concurrency of 0 is one of the cases in which Lambda drops asynchronous events, and if an on-failure destination (or a dead-letter queue) is configured, each event is sent there before it is dropped. So with a destination in place nothing is lost: the records wait in the failure queue and you replay them after the fix, using the reprocessing patterns above. Without one, events that arrive during the pause are gone. Configure the destination first, then throttle.
Ordinary throttling, when a function simply runs out of concurrency under load, behaves differently: for throttling (429) and system errors, Lambda keeps the event in its internal queue and retries for up to 6 hours by default, with the wait growing from 1 second to a maximum of 5 minutes between attempts. An event that outlives the maximum event age you set is dropped, and the destination record then carries the condition EventAgeExceeded instead of RetriesExhausted, which tells you the problem was capacity, not your code.
Throttling stops the function, not its triggers. To stop new work arriving at all, also switch off the source: disable the EventBridge rule or schedule, set the SQS, Kinesis or DynamoDB event source mapping to disabled with aws lambda update-event-source-mapping --uuid ... --no-enabled, or remove the S3 event notification. Synchronous callers, such as API Gateway, get an immediate 429 error while the function is throttled, so put a maintenance message in front of anything customers call directly.
Track the failure rate with a CloudWatch alarm
An alarm on every single failure is noisy; an alarm on the failure rate tells you when something has actually changed. CloudWatch metric math divides the function's Errors by its Invocations over the same period. Save this as error-rate.json:
[
{"Id": "rate", "Expression": "100*errors/invocations", "Label": "Error rate %", "ReturnData": true},
{"Id": "errors", "ReturnData": false, "MetricStat": {"Period": 300, "Stat": "Sum",
"Metric": {"Namespace": "AWS/Lambda", "MetricName": "Errors",
"Dimensions": [{"Name": "FunctionName", "Value": "repair-text"}]}}},
{"Id": "invocations", "ReturnData": false, "MetricStat": {"Period": 300, "Stat": "Sum",
"Metric": {"Namespace": "AWS/Lambda", "MetricName": "Invocations",
"Dimensions": [{"Name": "FunctionName", "Value": "repair-text"}]}}}
]
Then create the alarm, here for more than 5% failures in three 5-minute periods in a row, notifying an SNS topic:
aws cloudwatch put-metric-alarm --alarm-name repair-text-error-rate \ --metrics file://error-rate.json --comparison-operator GreaterThanThreshold \ --threshold 5 --evaluation-periods 3 --datapoints-to-alarm 3 \ --treat-missing-data notBreaching \ --alarm-actions arn:aws:sns:us-east-1:111122223333:ops-alerts
Note that Errors and Invocations count every attempt, so one event that fails three times adds three to both; the rate still works, but the raw counts look higher than the number of real events. For asynchronous functions, two more metrics complete the picture. AsyncEventsDropped counts events Lambda gave up on (retries used up, event too old, or concurrency set to 0), including those it sent to your on-failure destination first, so every drop should have a matching record in your failure queue; drops with no matching record mean events are being lost. AsyncEventAge shows how long events wait in the queue before they run, and a rising age is the early warning that throttling or errors are building a backlog. Together with DestinationDeliveryFailures from the earlier section, these alarms catch the ways a destination setup can quietly stop working.
The checklist, in order
- Is the function invoked asynchronously? Test with
--invocation-type Event, not the Test button. - Does the function actually fail, by raising an error, rather than catching it and returning?
- Has enough time passed for retries? Set retries to 0 while testing.
- Is DestinationDeliveryFailures above zero? If so, check permissions, FIFO and size.
- Does the execution role allow the destination action (and the KMS key, if encrypted)?
- Is the target a standard queue or topic, not FIFO?
- Is the destination set on the same version or alias the trigger invokes?
- Is the trigger an SQS, Kinesis, DynamoDB or Kafka event source? Configure failures on the source or the mapping instead.
- For EventBridge destinations, does your rule pattern match source
lambdaand the right detail-type?
Frequently asked questions
Why is my Lambda destination not working?
Usually because the function was invoked synchronously, such as from the console Test button or API Gateway. Destinations only fire for asynchronous invocations. Then check the execution role's permissions, FIFO targets and retry timing.
Do Lambda destinations work with the console Test button?
No. The Test button invokes the function synchronously. Test with aws lambda invoke --invocation-type Event to trigger destinations.
What is a Lambda destination?
A target that receives a record of an asynchronous invocation: an SQS queue, SNS topic, Lambda function, EventBridge bus, or, for failures only, an S3 bucket.
Which destinations does Lambda support?
Standard SQS queues, standard SNS topics, Lambda functions and EventBridge event buses, for both success and failure, plus S3 buckets for failures only.
What permissions does a Lambda destination need?
The function's execution role needs sqs:SendMessage, sns:Publish, lambda:InvokeFunction, events:PutEvents, or s3:PutObject and s3:ListBucket, depending on the destination.
Can a Lambda destination be a FIFO queue?
No. Destinations and dead-letter queues support standard SQS queues and standard SNS topics only. Deliveries to FIFO targets fail.
How long until an on-failure destination receives the record?
For function errors, after the retries: about three minutes with the default two retries. For throttling and system errors, retries can last up to six hours by default.
What is the difference between Lambda destinations and a DLQ?
A destination sends the event plus the response and error, supports more targets and can be set per alias. A DLQ sends only the event, to SQS or SNS, and is set per function.
Do destinations work with an SQS trigger?
No. For an SQS trigger, configure a dead-letter queue on the source queue. Kinesis, DynamoDB Streams and Kafka triggers have their own on-failure destination on the event source mapping.
How do I check if Lambda failed to deliver to a destination?
Look at the DestinationDeliveryFailures metric in CloudWatch for the function. Any value above zero means Lambda tried and could not deliver the record.
Why does my on-success destination fire when the function failed?
The function caught the error and returned normally, so Lambda counted it as a success. Raise or throw the error to make the invocation fail.
How do I set Lambda destinations in Terraform?
Use the aws_lambda_function_event_invoke_config resource with a destination_config block, and add the matching permission to the function's role.
How do I add a Lambda destination with the CDK?
Set onFailure or onSuccess on the function, using classes such as SqsDestination from aws-cdk-lib/aws-lambda-destinations. The CDK adds the role permission.
What does an EventBridge destination send?
An event with source lambda and detail-type Lambda Function Invocation Result - Success or - Failure, with the invocation record in the detail.
What are the maximum retry attempts and event age for async Lambda?
Retry attempts can be 0 to 2 (default 2). Maximum event age can be 60 seconds to 6 hours (default 6 hours).
How do I test Lambda destinations locally?
You cannot fully: sam local invoke runs the code once, synchronously, with no retries or destinations. Test the failure locally, then run one asynchronous test in a development account.
How do I get an email when a Lambda function fails?
Set a standard SNS topic as the on-failure destination, subscribe your email, confirm the subscription, and give the function's role sns:Publish on the topic.
Why does my EventBridge destination not trigger my rule?
The rule must be on the same event bus as the destination and match source lambda with detail-type Lambda Function Invocation Result - Failure or - Success.
How do I stop a Lambda function that keeps failing?
Set its reserved concurrency to 0 (the Throttle button, or aws lambda put-function-concurrency --reserved-concurrent-executions 0), then disable its triggers. With concurrency at 0, asynchronous events are dropped, after being sent to your on-failure destination if you have one, so set the destination up first and replay the records once the fix is live. Remove the throttle with delete-function-concurrency.
How do I monitor the Lambda failure rate?
Use CloudWatch metric math, 100 × Errors / Invocations over the same period, and alarm when it stays above your threshold for several periods. For asynchronous functions, also alarm on AsyncEventsDropped and DestinationDeliveryFailures.
Can I send large failed events to a destination?
Use SQS or S3 for large events. SNS messages are limited to 256 KB, so a record for a large event can fail to deliver to an SNS destination.
For readers preparing for an AWS certification
We have been covering AWS certifications alongside our fixes, so here is a short extra for readers studying for one. If you only came for the fix, you already have it; feel free to skip this part.
Exams that test this topic: Developer – Associate (DVA-C03 from December 1, 2026) • Solutions Architect – Associate (SAA-C03) • CloudOps Engineer – Associate (SOA-C03).
Here are the exam-style questions this topic tends to raise, with the reasoning you need to answer them:
1. A function invoked by S3 events must keep a record of events that fail every retry, including the error. What do you configure?
An on-failure destination for asynchronous invocation, such as an SQS queue, SNS topic, EventBridge bus, Lambda function or S3 bucket.
2. After a function finishes an asynchronous event successfully, the next service must be notified without extra code. What do you use?
An on-success destination.
3. A function is invoked through API Gateway with a Lambda proxy integration. Its on-failure destination never receives anything. Why?
The invocation is synchronous, and destinations apply only to asynchronous invocations.
4. A function is triggered by an SQS queue. Where do messages that keep failing go?
To a dead-letter queue configured on the source queue through its redrive policy.
5. Failed records from a Kinesis stream trigger must be kept for later analysis. Where do you configure that?
An on-failure destination on the event source mapping, with its own retry and record-age settings.
6. Failed asynchronous events must be held for one hour at most and retried only once. Which settings do you change?
The maximum event age (3,600 seconds) and maximum retry attempts (1) in the asynchronous invocation configuration.
7. Deliveries to a function's destination are failing. Which CloudWatch metric shows this?
DestinationDeliveryFailures.
Exam codes and dates change often. Our AWS certification changes page keeps track of what is current.
Ethan ran one asynchronous test with retries set to zero. Thirty seconds later the failure queue had its first record, with the customer's number, the message and the provider's 503 error. He set retries back to two, added an alarm on the queue, and wrote a tiny function to resend the texts each morning. "Six presses of the Test button," Jake said, "and the answer was not to press it." "Press it for testing the code," said Ethan. "Use the async invoke for testing the safety net."
📌 If you keep one line from this page
Lambda destinations only fire for asynchronous invocations, so test with --invocation-type Event, give the execution role the destination's permission, use standard (not FIFO) queues and topics, configure the same alias your trigger calls, and allow time for retries.
DestinationDeliveryFailures metric · retries 0 to 2 · event age up to 6 hours · SQS triggers use the source queue's DLQ · SNS records max 256 KB.
Revision note. Written October 19, 2026. "No news is good news," the saying goes; with Lambda destinations, no news usually means the call was synchronous.