
Most contact forms are built to do everything in one breath.
A request comes in. One function validates it, writes it down, calls the email API, waits for the "sent" response, and only then tells the user "thanks, we got it." It works fine on your laptop. It works fine in the demo. Then a real spike shows up — a launch, a campaign — and the whole thing gets slow or starts dropping submissions, because the user's "thanks" is chained to the slowest, most failure-prone step in the chain: sending the email.
Here's the one idea worth taking from this issue: accept fast, process later.
The problem is the coupling, not the traffic
The naive version looks reasonable — one Lambda behind API Gateway does the whole job: validate, save, send the email through SES (Amazon's email service), return 200.
The trouble is that 200 waits on the email. If SES is briefly slow, the user waits. If the email API rate-limits you mid-spike, the request fails — and the person who filled out your form never learns their message didn't make it. You've tied "did we safely receive this?" to "did we finish all the slow downstream work?" Those are two different questions, and in the moment the user only cares about the first.
The shape that holds: put a queue in the middle
This pattern has a name — queue-based load levelling. You drop a durable queue between accepting the submission and processing it, so a spike gets buffered instead of dropped:

Drawn in DesignBeaver.app
The front door does almost nothing: accept the request, put it on the queue, return 200. That's it. The user gets an instant, honest "we've got it" — because at that point, you genuinely do. Everything slow happens on the other side of the queue: a processor Lambda picks the message up and does the real work — store it in DynamoDB, send the email through SES. If SES is throttling during the spike, it doesn't matter. The message is sitting safely in the queue, waiting, and the user is long gone with their confirmation.
One thing worth knowing: you often don't even need the accepting Lambda. API Gateway can integrate with SQS directly — validate the request at the edge, drop it on the queue, done. Fewer moving parts, nothing to cold-start on the way in. I've run contact forms as one shared pipeline behind multiple client sites, and this is the shape that made a new site a config change, not a new backend.
SQS or SNS? (they are not interchangeable)
This is where a lot of write-ups get vague, so let's be precise:
SQS is a queue — each message is handled once, by one consumer. For a plain contact form, this is what you want.
SNS is fan-out — one message copied to many subscribers. You reach for it only when several independent things must react to one submission: email the team and push to your CRM and ping a Slack channel. The clean pattern there is SNS → several SQS queues, one per consumer, each buffering its own work.
So: start with SQS. Add SNS only when you genuinely have more than one thing reacting. Don't reach for fan-out you don't need.
The boring parts that make it production-grade
The queue buys you resilience, but decoupling has a cost you have to pay back deliberately:
A dead-letter queue is mandatory, not optional. Set an SQS redrive policy (
maxReceiveCount) so a message that keeps failing lands in a DLQ instead of being retried forever — or worse, silently dropped. The moment you return200before the email is sent, a failure happens somewhere the user can't see. The DLQ is what stops that from becoming silent data loss.Make the consumer idempotent. SQS gives you at-least-once delivery, so the same message can arrive twice. Check whether you've already sent the email for this submission before you send it again — otherwise a rare duplicate becomes a double email.
Set the visibility timeout — how long a message stays hidden while a consumer works it — to at least ~6× your processing time, so a slow send doesn't cause the message to reappear and get handled twice.
Decouple accept from process, and you now own delivery reliability yourself. That's the trade: the queue buys spike resilience; the DLQ and an idempotent consumer are what keep that resilience honest.
One thing to try
Look at your own submit handler and ask one question: does the user's success response depend on anything slower than "we safely wrote this down somewhere durable"? If it waits on an email send, a third-party API, a payment call — that's the thing to move to the other side of a queue. Your 200 should mean received, never finished.


