Amazon SES Error Handling and Retries in Node.js Bounces, Complaints, and Failures
Learn how to handle Amazon SES errors in Node.js bounces, complaints, throttling, authentication failures, and implementing retry logic.
Amazon SES Error Handling and Retries
Emails can fail for many reasons. This guide covers handling SES errors, bounces, complaints, and implementing retry logic.
Common SES Errors
| Error | Code | Cause | Action |
|---|---|---|---|
| Invalid recipient | 400 | Bad email format | Validate before sending |
| Sandbox restriction | 453 | Unverified recipient | Verify email or request production |
| Throttling | 429 | Too many emails/second | Slow down or request quota increase |
| Authentication failed | 403 | Wrong credentials | Check AWS keys |
| Message rejected | 554 | Spam content or blocked | Fix content, check reputation |
| Bounce | N/A | Recipient doesn't exist | Mark as bounced, don't resend |
| Complaint | N/A | User marked as spam | Mark as complained, don't resend |
Basic Error Handling
const { SendEmailCommand } = require("@aws-sdk/client-ses"); const sesClient = require("./sesClient"); const sendEmailWithRetry = async (params, retries = 3) => { for (let i = 0; i < retries; i++) { try { const result = await sesClient.send(new SendEmailCommand(params)); return result; } catch (err) { console.error(`Email attempt ${i + 1} failed:`, err.message); // Don't retry on these errors if (err.name === "MessageRejected") { throw new Error(`Email rejected: ${err.message}`); } if (err.name === "InvalidParameterValue") { throw new Error(`Invalid email: ${err.message}`); } // Retry on these errors if (err.name === "ThrottlingException" || err.statusCode === 429) { const delay = Math.pow(2, i) * 1000; // 1s, 2s, 4s await new Promise(resolve => setTimeout(resolve, delay)); continue; } if (i === retries - 1) throw err; } } };
Handling Bounces
Bounces occur when an email can't be delivered:
Hard bounce Permanent failure (invalid email, domain doesn't exist) Soft bounce Temporary failure (mailbox full, server down)
// Set up SNS notification for bounces // SES → Configuration Sets → Event Publishing → SNS topic // SNS webhook handler router.post("/ses/webhook", express.json(), asyncHandler(async (req, res) => { const notification = JSON.parse(req.body.Message); if (notification.notificationType === "Bounce") { const bounce = notification.bounce; const email = bounce.bouncedRecipients[0].emailAddress; const bounceType = bounce.bounceType; // "Permanent" or "Transient" if (bounceType === "Permanent") { await User.findOneAndUpdate( { email }, { emailBounced: true, emailBouncedAt: new Date() } ); console.log(`Permanent bounce: ${email}`); } } if (notification.notificationType === "Complaint") { const complaint = notification.complaint; const email = complaint.complainedRecipients[0].emailAddress; await User.findOneAndUpdate( { email }, { emailComplained: true, emailComplainedAt: new Date() } ); console.log(`Complaint: ${email}`); } res.status(200).json({ status: "ok" }); }));
Check Before Sending
const canSendEmail = (user) => { if (!user) return false; if (user.emailBounced) return false; if (user.emailComplained) return false; if (!user.email) return false; return true; }; const sendSafeEmail = async (user, template, data) => { if (!canSendEmail(user)) { console.log(`Skipping email to ${user?.email} (bounced or complained)`); return null; } try { return await sendEmailWithRetry({ Source: process.env.SES_FROM, Destination: { ToAddresses: [user.email] }, Message: { Subject: { Data: templates[template](data).subject, Charset: "UTF-8" }, Body: { Html: { Data: templates[template](data).html, Charset: "UTF-8" }, Text: { Data: templates[template](data).text, Charset: "UTF-8" } } } }); } catch (err) { console.error("Email failed after retries:", err); // Store failed email for later retry await FailedEmail.create({ userId: user._id, email: user.email, template, data, error: err.message, createdAt: new Date() }); return null; } };
Rate Limiting
SES has sending limits. Don't exceed them:
// Check sending limits const { GetSendQuotaCommand } = require("@aws-sdk/client-ses"); const checkQuota = async () => { const result = await sesClient.send(new GetSendQuotaCommand({})); console.log({ max24HourSend: result.Max24HourSend, maxSendRate: result.MaxSendRate, // emails per second sentLast24Hours: result.SentLast24Hours }); if (result.SentLast24Hours >= result.Max24HourSend) { throw new Error("Daily sending limit reached"); } };
Logging Email Events
// Log every email send attempt const logEmail = async (userId, email, template, status, error = null) => { await EmailLog.create({ userId, email, template, status, // "sent", "failed", "bounced" error, timestamp: new Date() }); }; // Use in sendSafeEmail await logEmail(user._id, user.email, template, "sent");
The Takeaway
SES error handling involves: retrying on throttling (429) with exponential backoff, not retrying on rejections (bad content or invalid email), handling bounces and complaints via SNS webhooks, checking before sending (skip bounced/complained emails), rate limiting to stay within SES quotas, and logging all email events for debugging. Use a dead-letter queue or database table for permanently failed emails.
Invalid recipient (400, bad email format), sandbox restriction (453, unverified recipient), throttling (429, too many emails/second), authentication failed (403, wrong credentials), message rejected (554, spam content), bounce (recipient doesn't exist), and complaint (user marked as spam).
Set up SNS notifications in SES configuration sets. Create a webhook endpoint that receives SNS notifications. When a bounce occurs, parse the notification, extract the email address and bounce type. For permanent bounces, mark the user as emailBounced in your database and never send to them again.
Implement retry with exponential backoff. Retry on ThrottlingException (429) with delays of 1s, 2s, 4s. Don't retry on MessageRejected or InvalidParameterValue (fix the issue instead). Use Bull queue for automatic retry with attempts and backoff settings.
Track bounces in your database (emailBounced: true, emailBouncedAt). Before sending, check canSendEmail(user) which returns false if user.emailBounced or user.emailComplained is true. Never send to bounced or complained addresses to protect your sender reputation.
Use GetSendQuotaCommand from the AWS SDK. It returns Max24HourSend (daily limit), MaxSendRate (emails per second), and SentLast24Hours (sent today). If SentLast24Hours >= Max24HourSend, stop sending. Request a quota increase from SES console if needed.
Ready to master Node.js completely?
Want to upskill yourself, crack your next interview, and get your dream job? Join our comprehensive course to dive deeper with high-quality video tutorials, solve interview questions, and a premium community.
Master Node.js
Want to upskill yourself, crack your next interview, and get your dream job? Join our comprehensive course.

