SQS vs. direct Lambda-to-Lambda invocation: why the queue matters
Chaining Lambdas with a direct call looks simpler than adding a queue. Here's why an SQS queue between them is the right default for async work — and exactly what the direct call drops under load.
You have Function A that needs to hand work to Function B. The obvious move is to have A call B directly — a synchronous invoke through the AWS SDK, wait for the response, move on. One less thing to draw, one less thing to configure. Why add a queue?
Here’s the short version, and it’s a position, not a maybe: if the work is asynchronous — if A doesn’t need B’s result inside the same request — put an SQS queue between them. The direct call looks simpler, and it is simpler right up until traffic spikes or B has a bad day. Then it drops work, with nowhere for the failure to land. The queue is what keeps that from happening. Let’s walk through the mechanism, because “add a queue” is only useful advice if you understand why.
The two designs, side by side
The direct version is exactly what it sounds like. A invokes B and blocks until B answers:
The queued version puts SQS in the middle. A drops a message on the queue and returns immediately; B pulls messages off on its own schedule:
Same intent — A gets work to B — but the second design survives conditions the first one doesn’t. This is also the decision Design Beaver is built to have an opinion about. It checks for the queue as you draw: async work between two functions wants one in between, and a queue without a dead-letter queue is only half the pattern. Wire a queue to a consumer with no DLQ and it surfaces that as a note — a call for you to make deliberately, not a hard error. We’ll come back to what that looks like. First, here’s what those conditions are, and why the queue earns its place.
Why the direct call bites you under load
A queue exists to decouple the parts of a system: a producer drops a message and a consumer pulls it off on its own schedule, so a slow or failing consumer never blocks the producer. That sentence is the whole argument, and the direct call is what happens when you take it away.
Lambda scales automatically, per request — every concurrent invocation spins up its own execution environment. That’s great for A. But when A invokes B synchronously, A is now coupled to B’s ability to keep up in that exact moment. If a burst of requests hits and B can’t scale fast enough, or B is throttled, or B is just erroring, there’s no slack in the system. The work has nowhere to wait. The failure comes straight back to A, and A is holding a message with nothing to do about it — there’s no queue retaining the message and nothing to retry it later on A’s behalf. Whatever B didn’t process is simply gone, unless you’ve hand-rolled buffering and retry logic inside A, which is to say, unless you’ve rebuilt a worse queue by hand.
This is the failure mode that doesn’t show up in a demo. Two Lambdas wired directly together work perfectly at one request at a time. They break under exactly the conditions you built them for: real, bursty, concurrent load.
What the queue actually buys you
Put SQS between A and B and three things change, each of them concrete.
A stops waiting. A calls sqs:SendMessage and returns. It has handed off the work durably — the message sits in the queue whether or not B is healthy right now. This is the canonical decoupling pattern: an API handler enqueues an email or notification job instead of doing it inline in the request.
Bursts get absorbed. The queue is a buffer. A traffic spike lands in the queue and drains at whatever rate B can sustain, instead of slamming into B all at once. B processes at its own steady pace rather than being forced to match A’s arrival rate spike-for-spike.
B pulls instead of getting pushed. You wire B to the queue with an event source mapping: Lambda long-polls the queue and invokes B one batch at a time, deleting the messages after a batch succeeds. That “after a batch succeeds” is the important part — if B fails a batch, the messages aren’t deleted. They become visible again and get another attempt, instead of vanishing the way a failed direct invoke does.
None of this requires B to know A exists. That’s the point of decoupling — either side can fail, scale, or get redeployed independently of the other.
The dead-letter queue: where failures land
Retrying forever isn’t the goal either. Some messages are just bad — a malformed payload, a record that references something already deleted — and no number of retries will fix them. Left alone, a message that always fails is retried until it expires, wasting consumer invocations and hiding the failure. There’s nowhere to go look at what went wrong.
That’s what a dead-letter queue is for. You attach a second queue as the DLQ and set a redrive policy on the source queue: name the DLQ as its deadLetterTargetArn with a maxReceiveCount — around 5 is a sane default. After a message has been received and failed that many times, SQS moves it to the dead-letter queue instead of putting it back for another round. Poison messages get set aside for inspection instead of being retried forever, and your consumer stops burning invocations on work it can never complete.
One constraint worth knowing up front: the dead-letter queue has to live in the same account and Region as the source queue, and its type has to match — a standard source queue needs a standard DLQ, a FIFO source needs a FIFO DLQ.
The direct Lambda-to-Lambda call has no equivalent to any of this. There’s no maxReceiveCount, because there’s no queue counting receives. A failed message doesn’t get set aside; it just doesn’t exist anymore.
The queue isn’t free — two things to get right
Adding a queue is the right default, but it’s not zero-effort, and pretending otherwise would be the same hand-waving this post is arguing against.
Set the visibility timeout above the function’s timeout. When Lambda pulls a message, SQS hides it for the visibility timeout so no other consumer grabs it while B is working. If that timeout is shorter than B’s function timeout, a slow invocation can let the same message become visible again and get processed twice. Set the queue’s visibility timeout to comfortably exceed the consumer function’s timeout.
Don’t ship the queue without the DLQ. A queue with no dead-letter queue configured is its own anti-pattern — you’ve got the buffering but none of the failure isolation, so a poison message still loops until it expires. The queue and the dead-letter queue are a pair; draw both.
And to be fair to the direct call: it isn’t always wrong. If the caller genuinely needs B’s result inside the same request — a synchronous response in the same request/response cycle — then a queue is the wrong tool, and you’d invoke directly (or skip the second function entirely). The queue is for async work. The mistake is reaching for a direct invoke because it’s fewer boxes, when the work was asynchronous all along.
One more boundary: a queue is point-to-point. One message is consumed once, by one consumer group. If you actually need one event delivered to several independent consumers, that’s not a bare queue — that’s SNS fanning out to multiple subscribed SQS queues. Using a single queue for fan-out doesn’t work: a second consumer can’t also read a message the first one already took.
How this shows up in Design Beaver
This is the exact kind of decision Design Beaver is built to have an opinion about. Draw Lambda A calling Lambda B directly for async work and a generic diagramming tool renders the line and says nothing — to a blank canvas, a connection is just a line. Design Beaver validates as you draw: it knows async work wants a queue in between, and it knows a queue without a dead-letter queue is only half the pattern. Wire an SQS queue to a Lambda consumer with no DLQ attached and it surfaces that on the canvas as a note — not a hard error, because a DLQ is a deliberate call and it stays with you — nudging you toward the same failure isolation we just walked through while you’re still drawing, instead of leaving a burst of production traffic to find it for you.
It’s the same philosophy behind why we validate a Lambda-to-RDS edge instead of rendering it silently: the connections that look fine on a canvas are exactly the ones that break under real load, and the time to catch them is before you deploy.
The takeaway
Direct Lambda-to-Lambda invocation trades a resilient system for one fewer box on the diagram. For synchronous work where the caller needs the answer now, that trade is fine. For asynchronous work — which is most of what you’d chain two functions together for — it drops work under load and gives failures nowhere to go. The SQS queue is the buffer that absorbs the burst, the decoupling that lets each function fail independently, and, with a dead-letter queue, the place a bad message lands instead of disappearing. Make the queue the default and reach for the direct call only when you can name the reason.
If you want a tool that catches the missing queue while you’re drawing the architecture, not after it breaks, open Design Beaver — it’s live in beta, free, no account required.
Try Design Beaver on your own architecture
It’s live in beta — free, in your browser, no account required.
Open the app →Prefer email? Get new features in your inbox: