I Added SQS to My Backend :)
Here’s a mistake I almost made: I was building the onboarding flow for my app NutriCal, and right before returning a success response to the user, I was about to write one line - await sendWelcomeEmail(user.email).
Simple. Clean. Terrible idea.
What would that one line actually do? It would make the entire onboarding request wait for Resend (my email provider) to respond before telling the user they’re good to go. If Resend is having a bad day - slow response, temporary outage, anything - the user just sits there staring at a loading spinner. For an email they don’t even know is being sent. For something that has absolutely nothing to do with whether their account was created successfully.
This is the story of how I fixed that with Amazon SQS, and what I learned building it.
The problem, put simply
When a user finishes onboarding on NutriCal, two things need to happen:
- Save their profile to the database ← the user is literally waiting for this
- Send them a welcome email ← the user has no idea this is happening
The mistake is treating both of these as equally urgent. They’re not. The profile write is the thing that matters. The email is a nice-to-have side effect.
The fix is to stop doing both things in the same step.
What’s a queue and why do you want one here?
Think of an SQS queue like a to-do list that lives in the cloud. Instead of doing a task right now, you write it down on the list and walk away. Some other worker comes along later, picks up the task, does it, and crosses it off.
In code terms:
- Your API receives the request, drops a “send welcome email” note on the queue, and immediately returns success to the user
- A separate Lambda function sees that note on the queue and actually sends the email - on its own time, in its own invocation, with its own retry logic
The user gets their success response in milliseconds. The email goes out shortly after. If the email fails for any reason, the queue can retry it automatically. Nobody’s onboarding gets ruined because Resend had a hiccup.
The full picture
Here’s how the whole thing flows:
User finishes onboarding
↓
Frontend fires POST /api/notifications/welcome-email
(doesn't wait for a response - just sends it and moves on)
↓
Backend gets the request, verifies auth, drops a message on the SQS queue
Returns 202 Accepted immediately
↓
SQS Queue holds the message
↓
notificationWorker Lambda picks it up
(Calls Resend, sends the email)
Two separate Lambda functions. Two separate jobs. They never block each other.
Walking through the code
Wait - why a separate API call just for this?
You might be wondering: why not just enqueue the welcome email directly inside the onboarding handler, the moment the profile is saved? That would be cleaner, one request, one place.
The reason is that my backend doesn’t own onboarding. Supabase does. The user signup, auth, and profile write all happen through Supabase, my backend is never in that loop. So there’s no “onboarding handler” on my backend that could enqueue anything. By the time my backend could know a user has just been onboarded, the moment has already passed.
That’s why the frontend has to explicitly tell the backend - “hey, this user just finished onboarding, go send the welcome email.” The separate API call is not overhead; it’s the only trigger point available, given how the stack is set up.
If you owned the onboarding flow entirely in your backend, you could absolutely skip this extra call and enqueue directly after the profile write succeeds. That would be the cleaner approach.
Step 1: The frontend doesn’t wait
const sendWelcomeEmail = (session) => {
axios.post(`${import.meta.env.VITE_BACKEND_URL}/api/notifications/welcome-email`, {}, {
headers: { Authorization: `Bearer ${session.access_token}` }
}).catch((error) => {
console.error('Error sending welcome email:', error);
});
};Notice there’s no await here. The frontend fires this request and immediately moves on - onComplete() runs regardless of what happens with the email.
This is called fire-and-forget, and it’s the first layer of decoupling.
Step 2: The controller just enqueues, then responds
const welcomeEmailController = async (req, res) => {
try {
const { data, error } = await req.supabase.auth.getUser();
if (error || !data?.user?.email) {
return res.status(400).json({ success: false, message: "Could not resolve user email" });
}
const name = data.user.user_metadata?.full_name || "";
await queueService.enqueue(JOB_TYPES.SEND_WELCOME_EMAIL, { email: data.user.email, name });
return res.status(202).json({ success: true });
} catch (e) {
console.log(e);
return res.status(500).json({ success: false, message: "Internal Server Error" });
}
};The response is 202 Accepted, not 200 OK. This is the correct HTTP status for "I've received your request and will process it, but I haven't necessarily finished yet." The controller's work is done the moment SQS acknowledges the message, not when the email lands in someone's inbox.
Step 3: The queue service (with a clean abstraction)
// queueService.js
import sqsProvider from "./provider/sqs.js";
const providers = { sqs: sqsProvider };
const enqueue = async (jobType, payload) => {
const providerKey = process.env.QUEUE_PROVIDER?.toLowerCase() || "sqs";
const provider = providers[providerKey];
if (!provider) throw new Error("Invalid queue provider");
return provider.enqueue(jobType, payload);
};
export default { enqueue };
// provider/sqs.js
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
const client = new SQSClient({ region: process.env.AWS_REGION || "ap-south-1" });
const enqueue = async (jobType, payload) => {
const command = new SendMessageCommand({
QueueUrl: process.env.NOTIFICATIONS_QUEUE_URL,
MessageBody: JSON.stringify({ type: jobType, payload }),
});
return client.send(command);
};
The controller only ever calls queueService.enqueue(...). It never imports the AWS SDK directly. If I ever want to swap SQS for something else, I write one new provider file and change an env var. Zero other changes.
Step 4: The worker - where emails actually get sent
This is a completely separate function. Not a route. Not part of the Express app. AWS invokes it automatically whenever messages appear on the queue.
// workers/notificationWorker.js
import { JOB_TYPES } from "../services/queue/jobTypes.js";
import emailService from "../services/email/emailService.js";
const handlers = {
[JOB_TYPES.SEND_WELCOME_EMAIL]: emailService.sendWelcomeEmail,
};
export const handler = async (event) => {
const batchItemFailures = [];
for (const record of event.Records) {
try {
const { type, payload } = JSON.parse(record.body);
const jobHandler = handlers[type];
if (!jobHandler) {
console.log(`Unknown job type: ${type}`);
continue;
}
await jobHandler(payload);
} catch (e) {
console.log(e);
batchItemFailures.push({ itemIdentifier: record.messageId });
}
} return { batchItemFailures };
};
This pattern - for loop with a per-record try/catch is called partial batch failure handling, and it's the detail that makes batching actually safe.
Here’s the problem it solves: the worker is configured to receive up to 10 messages at once. If you use Promise.all and one message fails, the entire batch of 10 gets marked as failed, and all 10 get redelivered. That means 9 users get duplicate welcome emails because of one bad message.
By catching failures per-record and only adding the actually failed message IDs to batchItemFailures, only the genuinely broken message gets retried. The 9 successful ones are done.
Step 5: The serverless.yml wiring
functions:
app:
handler: handler.handler
events:
- httpApi: '*'
notificationWorker:
handler: workers/notificationWorker.handler
events:
- sqs:
arn: arn:aws:sqs:ap-south-1:YOUR_ACCOUNT_ID:nutrical-messages
batchSize: 10
functionResponseType: ReportBatchItemFailures
functionResponseType: ReportBatchItemFailures without this line, the batchItemFailures array the worker returns is completely ignored. AWS treats the entire batch as either all-success or all-failure. This one config key is what makes partial batch failure actually work. This is not a normal http request so we have to write is separately.
IAM scoped to the exact queue ARN:
We never added IAM for Supabase, Resend, Razorpay, or OpenAI, so why now? Because all of those are external services authenticated with API keys, IAM has nothing to do with them. SQS is the first time our Lambda needs to talk to another AWS service, and that’s exactly when IAM comes in. AWS services don’t accept API keys; they use IAM to control what’s allowed to talk to what.
functions:
app:
handler: handler.handler
events:
- httpApi: '*'
notificationWorker:
handler: workers/notificationWorker.handler
events:
- sqs:
arn: arn:aws:sqs:ap-south-1:YOUR_ACCOUNT_ID:nutrical-messages
batchSize: 10
functionResponseType: ReportBatchItemFailures
Not Resource: "*". Not the entire account. Just this one queue. The HTTP-facing Lambda is exposed to the internet - if something in it gets exploited, tight IAM permissions are the thing stopping an attacker from reaching anything else in your AWS account.
Why SQS and not BullMQ?
This is a fair question. There are a lot of queue options out there, so here’s why SQS won.
BullMQ doesn’t work with how I’m using Upstash. I already have Upstash Redis in the stack, so BullMQ seems like the obvious choice - Redis is right there. But there’s a catch. I’m using @upstash/redis, which is Upstash's HTTP-based client. It talks to Redis over HTTP, not a persistent TCP connection, which is exactly what makes it work well on Lambda (no connection management, no pool exhaustion).
BullMQ doesn’t support that. It requires ioredis or node-redis, both TCP-based clients that hold a persistent connection open. That’s a fundamentally different thing from the HTTP client I’m using. To use BullMQ, I’d have to add ioredis as a separate dependency, manage TCP connections on Lambda (which is painful), and on top of that, BullMQ polls Redis constantly even when there are no jobs, which, on Upstash’s per-request billing, means you’re paying for a queue that’s just sitting idle. Not worth it.
SQS just works with Lambda natively. You’re already on AWS. Your Lambda is on AWS. SQS is on AWS. The events: - sqs: block in serverless.yml It literally all takes to wire them together, no extra servers, no extra services, nothing to manage. AWS handles the queue infrastructure entirely.
Cost -> SQS’s free tier covers 1 million requests per month. For welcome emails, you’d have to be at a serious scale before it costs you anything meaningful.
What I’d do differently (honest retrospective)
No dead-letter queue. If a message fails every retry attempt, it just… disappears. For a welcome email, this is probably fine. For payment confirmations or anything that actually matters, you want a DLQ so failed messages land somewhere you can inspect and replay them rather than quietly vanishing. This is the first thing I’d add if I were queuing something more important.
The queue is provisioned by hand. I created the SQS queue in the AWS console instead of declaring it in serverless.yml as a resources: block. That means if I ever need to recreate the environment from scratch, I have to remember to manually create the queue and paste the ARN into the config. Not great. Would fix before taking this pattern anywhere that needs proper disaster recovery.
Building NutriCal and writing about what I learn along the way. The full codebase is a work in progress.