---
title: Amazon SES queued sending — SQS + Lambda rate limiting for burst and multi-tenant email
description: Reference architecture for queued Amazon SES sending: an SQS buffer, a Lambda consumer whose maximum concurrency is sized to your SES MaxSendRate, retry with backoff on ThrottlingException, DLQ redrive, per-tenant configuration sets, and CloudWatch reputation alarms.
url: https://www.factualminds.com/patterns/ses-queued-sending-sqs-lambda/
category: reliability
publishDate: 2026-10-24
updateDate: 2026-10-24
---

# Amazon SES queued sending — SQS + Lambda rate limiting for burst and multi-tenant email

> Your application produces email in spikes. Amazon SES accepts it at a fixed per-second rate. Put a queue between the two, size the consumer to the quota, and keep one tenant from spending the sending reputation every other tenant depends on.

## Why this pattern

Amazon SES gives every account a per-second maximum send rate and a 24-hour quota in each Region. Application traffic does not arrive at a steady per-second rate. A campaign launch, a batch job, or a burst of sign-ups calls SendEmail faster than the quota allows, and SES rejects the overflow with a `ThrottlingException`. That error is retriable, but in-process retries make the burst worse and lose messages on restart.

The fix is to separate *producing* email from *sending* it. Producers enqueue; one consumer drains the queue at a rate you control. This is the same decoupling covered in our [event-driven microservices pattern](https://www.factualminds.com/patterns/event-driven-microservices/), applied to the one downstream system with a hard per-second limit.

## Sizing the consumer to the quota

SES reports your limits through the SESv2 `GetAccount` API as `SendQuota.MaxSendRate` (emails per second) and `SendQuota.Max24HourSend`. The consumer's aggregate rate is roughly:

```
aggregate rate ≈ MaximumConcurrency × (emails sent per invocation ÷ invocation duration in seconds)
```

Pick `MaximumConcurrency` and batch size so the aggregate rate stays at 70–80% of `MaxSendRate`, measured with your own per-call latency. Re-read the quota on a schedule (an EventBridge Scheduler rule is enough), because quota increases otherwise go unused. Keep a separate queue, with its own reserved slice of the rate, for time-critical mail such as password resets and one-time passcodes.

## Handling throttling and failures

| SES response | Retry? | What the consumer does |
| --- | --- | --- |
| `ThrottlingException` — Maximum sending rate exceeded | Yes | Return the message as a batch item failure so it comes back after the visibility timeout |
| `ThrottlingException` — Daily message quota exceeded | Yes, later | Return the message; AWS guidance for either throttling error is to wait up to 10 minutes before retrying |
| Sending paused for the configuration set or account | Not until fixed | Return the message; it moves to the DLQ after `maxReceiveCount` and is redriven once sending resumes |
| Message rejected, identity not verified, or address suppressed | No | Log and delete; retrying cannot succeed |

## Reputation alarms belong in the same stack

AWS places an account under review at a bounce rate of 5% or more and may pause sending at 10%. For complaints, review starts at 0.1% and sending may pause at 0.5%. SES publishes these as the CloudWatch metrics `Reputation.BounceRate` and `Reputation.ComplaintRate` (values are ratios: 0.05 means 5%). Alarm at the AWS-recommended values and at an earlier warning level, and wire configuration-set event destinations (EventBridge or SNS) to your suppression handler. The full alarm and deliverability setup is in our [Amazon SES deliverability consulting](https://www.factualminds.com/services/aws-ses/) page; dedicated IP ramp-up is covered in the [SES dedicated IP warm-up guide](https://www.factualminds.com/blog/amazon-ses-dedicated-ip-warmup-2026/).

## When this pattern is overkill

If you send a few thousand transactional emails a day, far below your quota, a direct SDK call with the SDK's built-in retry is fine. Add the queue when any of these hold: bursts reach your MaxSendRate, you send on behalf of multiple tenants or brands, or losing an email on a deploy or restart is unacceptable. If you are moving to SES from another provider, see our [SES migration services](https://www.factualminds.com/services/aws-ses-migration/).

## Problem

Amazon SES enforces a per-second maximum send rate and a 24-hour quota per account per Region. When an application calls SendEmail directly from request handlers, a campaign launch, a password-reset storm, or a nightly digest job pushes the call rate past MaxSendRate, and SES rejects the excess with a ThrottlingException ("Maximum sending rate exceeded"). Teams then bolt on in-process retries that multiply the burst, drop messages when the process restarts, and give no single place to see what was not sent. In multi-tenant products the problem compounds: one tenant's bulk send consumes the shared rate, and one tenant's bad list pushes the account bounce rate toward the 5% review threshold for everyone.

## Solution

Decouple producers from SES with an Amazon SQS queue. Producers enqueue a small send request (recipient, template name, template data, tenant). A Lambda function consumes the queue through an event source mapping whose maximum concurrency, batch size, and per-message pacing are sized so the aggregate call rate stays under the account MaxSendRate read from GetAccount. Throttling responses are treated as retriable: the message returns to the queue with backoff instead of being dropped. Messages that keep failing move to a dead-letter queue for inspection and redrive. Each tenant (or each mail stream) sends through its own configuration set or SES tenant, so events, reputation metrics, and suppression scope stay per tenant, and CloudWatch alarms on Reputation.BounceRate and Reputation.ComplaintRate page someone before AWS places the account under review.

## AWS Services

- **Amazon SES (SESv2 API)** — Sends the email. GetAccount returns SendQuota (MaxSendRate per second, Max24HourSend); configuration sets carry event destinations and reputation metrics; SES tenants isolate reputation per customer in multi-tenant senders
- **Amazon SQS (standard or FIFO) + dead-letter queue** — Durable buffer between producers and SES. Standard for highest throughput; FIFO when per-tenant ordering or exactly-once enqueue matters, with MessageGroupId set to the tenant. The DLQ captures messages that exhaust maxReceiveCount
- **AWS Lambda (SQS event source mapping)** — Consumer. ScalingConfig MaximumConcurrency (2 to 1,000) caps how many invocations run at once, which is the main control for staying under MaxSendRate. Partial batch responses return only failed messages to the queue
- **Amazon CloudWatch** — Alarms on SES Reputation.BounceRate and Reputation.ComplaintRate, on SQS ApproximateAgeOfOldestMessage (backlog), and on DLQ depth
- **Amazon EventBridge or Amazon SNS** — Configuration-set event destinations for BOUNCE, COMPLAINT, DELIVERY, and REJECT events, feeding suppression handling and per-tenant reporting

## Components

### Enqueue, never send inline
Request handlers and batch jobs write one message per email (or per small recipient batch) to SQS and return immediately. Keep the payload small, a template name plus data, so each message stays inside one 64 KB SQS billing chunk. Use SendMessageBatch (up to 10 messages per call) from bulk producers.

### Consumer sized to MaxSendRate
Read MaxSendRate from GetAccount at deploy time and on a schedule. Set the event source mapping MaximumConcurrency and batch size so that concurrency × emails per second per invocation stays below MaxSendRate with headroom (target 70 to 80%). Measure your own per-call latency; do not assume it. Prefer MaximumConcurrency over reserved concurrency, as AWS recommends, so Lambda stops polling instead of throttling.

### Throttling is retriable
SES returns ThrottlingException with "Maximum sending rate exceeded" or "Daily message quota exceeded". Report those messages as batch item failures so they return to the queue after the visibility timeout; AWS guidance is to wait up to 10 minutes before retrying either throttling error. Permanent errors (unverified identity, suppressed address) are logged and deleted, not retried.

### DLQ and redrive
A redrive policy with a modest maxReceiveCount moves poison messages to a DLQ. Operators fix the cause (bad template data, a paused configuration set) and use SQS DLQ redrive to move messages back to the source queue, for standard and FIFO queues alike.

### Per-tenant isolation
One configuration set per tenant or mail stream (transactional vs marketing) keeps event destinations, reputation metrics, and dedicated IP pools separate. For SaaS senders, SES tenant management adds per-tenant reputation policies and suppression scope. With FIFO, MessageGroupId = tenant ID keeps one tenant's backlog from blocking another tenant's messages.

### Reputation alarms
CloudWatch alarms on Reputation.BounceRate at 0.05 (5%) and Reputation.ComplaintRate at 0.001 (0.1%), the levels AWS recommends, plus an earlier warning alarm of your choosing. To pause only the offending stream, a per-configuration-set alarm publishes to SNS, and a Lambda function calls PutConfigurationSetSendingOptions (SESv2) with SendingEnabled=false for that configuration set.

## Trade-offs

- **Pro:** Bursts become backlog instead of errors. A 10x spike drains at the quota rate and nothing is lost when a consumer restarts.
- **Con:** Email is no longer sent within the request. Time-critical mail (one-time passcodes, password resets) needs its own queue and its own share of MaxSendRate so a marketing backlog cannot delay it.

- **Pro:** One knob, MaximumConcurrency, controls the send rate, and raising it after an SES quota increase is a config change, not a code change.
- **Con:** MaximumConcurrency caps concurrency, not calls per second. If per-call latency drops, the rate rises. Pace inside the handler, or alarm on throttling counts, so a faster network does not push you over MaxSendRate.

- **Pro:** FIFO with MessageGroupId per tenant gives fairness and ordering per tenant for free.
- **Con:** FIFO queues default to 300 transactions per second per API action (3,000 messages per second with batching) unless high-throughput mode is enabled, and they cost more per request than standard queues. Use standard queues when ordering does not matter.

- **Pro:** Per-tenant configuration sets make bounce and complaint rates attributable, so the fix is "suppress tenant X" rather than "pause the account".
- **Con:** More configuration sets, alarms, and IAM conditions to manage in IaC. Below a handful of tenants, two configuration sets (transactional and marketing) are usually enough.

## Cost Estimate

The queue and consumer are small next to SES itself. List rates (us-east-1, from the pricing modules behind our calculators, as of 2026-10-17): SQS standard $0.40 per million requests and FIFO $0.50 per million, with the first million requests free each month on standard queues; Lambda $0.20 per million requests and $0.0000133334 per GB-second on arm64, with 1 million requests and 400,000 GB-seconds free each month. Amazon SES a-la-carte outbound is $0.10 per 1,000 emails. Worked example with stated assumptions: 10 million emails per month, one SendMessage per email (10 million requests), consumed through the Lambda event source mapping in batches of 10 (about 1 million ReceiveMessage and 1 million DeleteMessage requests), is 12 million SQS requests. On a standard queue that is (12 − 1 free) × $0.40 = $4.40. Consumed in batches of 10 (1 million Lambda invocations) at 512 MB and an assumed 1 second per batch, Lambda is about $6.87 before the free tier. SES send is $1,000. The queue-and-consumer layer ($4.40 + $6.87 = $11.27) is about 1.1% of the SES bill; batching producers with SendMessageBatch cuts the SQS line further.

## Related Patterns

- event-driven-microservices
- multi-tenant-saas-on-aws

## FAQ

### How do I stop "Maximum sending rate exceeded" errors in Amazon SES?
Stop calling SES directly from bursty code paths. Enqueue send requests in Amazon SQS and consume them with a Lambda function whose event source mapping MaximumConcurrency and batch size keep the aggregate call rate under your account MaxSendRate (read it from the SESv2 GetAccount API). Treat ThrottlingException as retriable by returning the message to the queue, and request a sending quota increase in the SES console when sustained demand is above the quota.

### Should I use reserved concurrency or maximum concurrency to rate-limit the SES consumer?
Use the SQS event source mapping maximum concurrency setting. With only reserved concurrency, the event source mapping can poll more messages than the function is allowed to process, which produces throttles and extra receives. Maximum concurrency (2 to 1,000) makes Lambda stop polling at the cap. You can use both; set maximum concurrency below reserved concurrency.

### Standard or FIFO queue for SES sending?
Standard queues for most email: throughput is effectively unlimited and duplicates are rare and harmless if your consumer is idempotent (dedupe on a message key). Choose FIFO when you need per-tenant ordering or fairness. Set MessageGroupId to the tenant so one tenant's backlog does not block others, and enable high-throughput mode if you need more than the default 300 transactions per second per API action.

### How do I keep one tenant from hurting deliverability for every other tenant?
Send each tenant through its own configuration set or SES tenant, publish bounce and complaint events per configuration set, and alarm on per-tenant rates. When a tenant spikes, pause only that configuration set (SNS to a Lambda function that calls PutConfigurationSetSendingOptions with SendingEnabled=false) while the rest of the account keeps sending. Account-level bounce rate of 5% or more puts the whole account under review, so per-tenant isolation is what keeps one bad list from becoming everyone's problem.

---

*Source: https://www.factualminds.com/patterns/ses-queued-sending-sqs-lambda/*
