Your app just sent 10,000 password reset emails in 3 minutes because someone hammered the "Forgot Password" button.
Or worse: a user enumeration attack. Or a revenge loop triggered by a webhook. Or an accidental bulk import that tried to send a welcome email to every row.
Here's how to build production-ready rate limiting and throttling: five patterns that protect your app, your users, and your deliverability.
1) Per-user rate limits: Stop spam at the source
Limit how many emails a single user can trigger in a time window. Stops abuse, accidents, and revenge loops.
import { redis } from '@/lib/redis';
export async function checkUserEmailRateLimit(
userId: string,
emailType: 'password-reset' | 'verification' | 'notification',
options: {
maxRequests: number;
windowSeconds: number;
}
): Promise<{ allowed: boolean; retryAfter?: number }> {
const key = `email-rate:${userId}:${emailType}`;
const now = Date.now();
const windowStart = now - options.windowSeconds * 1000;
// Remove old entries outside the window
await redis.zremrangebyscore(key, '-inf', windowStart);
// Count requests in current window
const count = await redis.zcard(key);
if (count >= options.maxRequests) {
// Get oldest entry to calculate retry time
const oldest = await redis.zrange(key, 0, 0, 'WITHSCORES');
const retryAfter = oldest[1]
? Math.ceil((Number(oldest[1]) + options.windowSeconds * 1000 - now) / 1000)
: options.windowSeconds;
return { allowed: false, retryAfter };
}
// Add current request
await redis.zadd(key, now, `${now}-${Math.random()}`);
await redis.expire(key, options.windowSeconds);
return { allowed: true };
}Use it before sending:
import { checkUserEmailRateLimit } from '@/lib/rate-limiter';
export async function POST(req: Request) {
const { email } = await req.json();
const user = await db.user.findUnique({ where: { email } });
if (!user) {
// Still return success to prevent user enumeration
return Response.json({ success: true });
}
// Rate limit: 3 password resets per hour
const rateCheck = await checkUserEmailRateLimit(user.id, 'password-reset', {
maxRequests: 3,
windowSeconds: 3600,
});
if (!rateCheck.allowed) {
return Response.json(
{
error: 'Too many reset requests',
retryAfter: rateCheck.retryAfter,
},
{ status: 429 }
);
}
// Proceed with reset email...
}- Password resets: 3-5 per hour
- Email verification: 5 per hour
- Notifications: 10-20 per hour
- Marketing emails: 1 per day (if user-triggered)
2) Global throttling: Respect ESP limits
ESPs have send rate limits. Resend: 10/sec on free tier. SendGrid varies by plan. Exceeding them gets your requests rejected or your account throttled.
import { redis } from '@/lib/redis';
export class EmailThrottle {
private key = 'email:global-throttle';
async shouldThrottle(
maxPerSecond: number
): Promise<{ throttle: boolean; waitMs?: number }> {
const now = Date.now();
const windowStart = now - 1000;
// Remove entries older than 1 second
await redis.zremrangebyscore(this.key, '-inf', windowStart);
// Count sends in last second
const count = await redis.zcard(this.key);
if (count >= maxPerSecond) {
// Calculate how long to wait
const oldest = await redis.zrange(this.key, 0, 0, 'WITHSCORES');
const waitMs = oldest[1]
? Math.max(0, Number(oldest[1]) + 1000 - now)
: 1000;
return { throttle: true, waitMs };
}
// Record this send
await redis.zadd(this.key, now, `${now}-${Math.random()}`);
await redis.expire(this.key, 2); // Clean up after 2 seconds
return { throttle: false };
}
}
export const emailThrottle = new EmailThrottle();Use it as a queue middleware:
import { Queue } from 'bullmq';
import { emailThrottle } from './email-throttle';
const emailQueue = new Queue('email', { connection: redis });
emailQueue.process(async (job) => {
// Check global throttle
const check = await emailThrottle.shouldThrottle(8); // 8/sec, safe margin
if (check.throttle && check.waitMs) {
// Delay job and retry
throw new Error(`Throttled: retry in ${check.waitMs}ms`);
}
// Send email
await sendEmail(job.data);
});3) IP-based rate limiting: Stop attackers
Anonymous endpoints (password reset, contact forms) need IP-based limits to prevent brute force and abuse.
import { headers } from 'next/headers';
import { redis } from '@/lib/redis';
export async function checkIpRateLimit(
action: string,
options: {
maxRequests: number;
windowSeconds: number;
}
): Promise<{ allowed: boolean; retryAfter?: number }> {
// Get real IP (consider proxies)
const headersList = headers();
const ip =
headersList.get('x-forwarded-for')?.split(',')[0] ||
headersList.get('x-real-ip') ||
'unknown';
const key = `ip-rate:${ip}:${action}`;
const now = Date.now();
const windowStart = now - options.windowSeconds * 1000;
await redis.zremrangebyscore(key, '-inf', windowStart);
const count = await redis.zcard(key);
if (count >= options.maxRequests) {
const oldest = await redis.zrange(key, 0, 0, 'WITHSCORES');
const retryAfter = oldest[1]
? Math.ceil((Number(oldest[1]) + options.windowSeconds * 1000 - now) / 1000)
: options.windowSeconds;
return { allowed: false, retryAfter };
}
await redis.zadd(key, now, `${now}-${Math.random()}`);
await redis.expire(key, options.windowSeconds);
return { allowed: true };
}Apply to public endpoints:
import { checkIpRateLimit } from '@/lib/ip-rate-limiter';
export async function POST(req: Request) {
// IP rate limit: 5 contact form submissions per hour
const ipCheck = await checkIpRateLimit('contact-form', {
maxRequests: 5,
windowSeconds: 3600,
});
if (!ipCheck.allowed) {
return Response.json(
{
error: 'Too many requests from this IP',
retryAfter: ipCheck.retryAfter,
},
{ status: 429 }
);
}
// Process contact form...
}CF-Connecting-IP header for the real IP. Never trust x-forwarded-for alone—it can be spoofed.4) Recipient deduplication: One email per address per window
Prevent duplicate sends to the same email address within a short time window. Protects against double-submit bugs and retry loops.
import { redis } from '@/lib/redis';
import { createHash } from 'crypto';
export async function checkEmailDeduplication(
recipient: string,
emailType: string,
windowSeconds: number = 60
): Promise<{ shouldSend: boolean; reason?: string }> {
// Hash email for privacy
const hash = createHash('sha256').update(recipient.toLowerCase()).digest('hex');
const key = `email-dedup:${emailType}:${hash}`;
const exists = await redis.get(key);
if (exists) {
return {
shouldSend: false,
reason: 'Duplicate send attempt within deduplication window',
};
}
// Set deduplication lock
await redis.setex(key, windowSeconds, Date.now().toString());
return { shouldSend: true };
}Use it before queuing:
import { checkEmailDeduplication } from './email-deduplication';
export async function sendPasswordResetEmail(email: string, token: string) {
// Check deduplication (60 second window)
const dedupCheck = await checkEmailDeduplication(email, 'password-reset', 60);
if (!dedupCheck.shouldSend) {
console.log('Skipping duplicate send:', dedupCheck.reason);
return { skipped: true, reason: dedupCheck.reason };
}
// Queue email send
await emailQueue.add('send', {
type: 'password-reset',
to: email,
token,
});
return { queued: true };
}- Transactional (reset, verification): 30-60 seconds
- Notifications: 5-10 minutes
- Marketing: 24 hours
5) Circuit breaker: Stop cascading failures
If your ESP starts failing, stop sending immediately. A circuit breaker prevents retry storms that make outages worse.
import { redis } from '@/lib/redis';
export class EmailCircuitBreaker {
private key = 'email:circuit-breaker';
private failureThreshold = 10; // Open circuit after 10 failures
private resetTimeoutSeconds = 60; // Try again after 60 seconds
async recordSuccess() {
await redis.del(this.key);
}
async recordFailure() {
const failures = await redis.incr(this.key);
await redis.expire(this.key, this.resetTimeoutSeconds);
if (failures >= this.failureThreshold) {
// Circuit is now open
console.error('🚨 Email circuit breaker OPEN - stopping all sends');
}
return failures;
}
async isOpen(): Promise<boolean> {
const failures = await redis.get(this.key);
return failures ? Number(failures) >= this.failureThreshold : false;
}
async getStatus(): Promise<{
state: 'closed' | 'open';
failures: number;
threshold: number;
}> {
const failures = Number((await redis.get(this.key)) || 0);
return {
state: failures >= this.failureThreshold ? 'open' : 'closed',
failures,
threshold: this.failureThreshold,
};
}
}
export const emailCircuitBreaker = new EmailCircuitBreaker();Use it in your send logic:
import { emailCircuitBreaker } from './email-circuit-breaker';
import { resend } from './resend';
export async function sendEmail(options: EmailOptions) {
// Check circuit breaker
if (await emailCircuitBreaker.isOpen()) {
throw new Error('Email circuit breaker is OPEN - not sending');
}
try {
const result = await resend.emails.send(options);
await emailCircuitBreaker.recordSuccess();
return result;
} catch (error) {
await emailCircuitBreaker.recordFailure();
throw error;
}
}Production setup: All patterns together
Here's what a fully protected email send flow looks like, combining all five patterns:
import { checkUserEmailRateLimit } from './rate-limiter';
import { emailThrottle } from './email-throttle';
import { checkIpRateLimit } from './ip-rate-limiter';
import { checkEmailDeduplication } from './email-deduplication';
import { emailCircuitBreaker } from './email-circuit-breaker';
import { sendEmail } from './email-sender';
export async function protectedSendEmail(options: {
userId?: string;
recipient: string;
emailType: string;
requireIpCheck?: boolean;
content: EmailContent;
}) {
// 1. Check circuit breaker first (fail fast)
if (await emailCircuitBreaker.isOpen()) {
throw new Error('Email system temporarily unavailable');
}
// 2. Per-user rate limit (if authenticated)
if (options.userId) {
const userLimit = await checkUserEmailRateLimit(
options.userId,
options.emailType as any,
{ maxRequests: 5, windowSeconds: 3600 }
);
if (!userLimit.allowed) {
throw new Error(`Rate limit exceeded. Try again in ${userLimit.retryAfter}s`);
}
}
// 3. IP rate limit (for public endpoints)
if (options.requireIpCheck) {
const ipLimit = await checkIpRateLimit(options.emailType, {
maxRequests: 10,
windowSeconds: 3600,
});
if (!ipLimit.allowed) {
throw new Error(`Too many requests. Try again in ${ipLimit.retryAfter}s`);
}
}
// 4. Deduplication check
const dedupCheck = await checkEmailDeduplication(
options.recipient,
options.emailType,
60
);
if (!dedupCheck.shouldSend) {
console.log('Skipping duplicate send');
return { skipped: true, reason: dedupCheck.reason };
}
// 5. Global throttle (respect ESP limits)
const throttleCheck = await emailThrottle.shouldThrottle(8);
if (throttleCheck.throttle && throttleCheck.waitMs) {
// In a real system, this would trigger a queue delay
await new Promise((resolve) => setTimeout(resolve, throttleCheck.waitMs));
}
// 6. Actually send the email
try {
const result = await sendEmail({
to: options.recipient,
...options.content,
});
return { sent: true, messageId: result.id };
} catch (error) {
console.error('Email send failed:', error);
throw error;
}
}Monitoring and alerts
Rate limiting only works if you watch it. Set up dashboards and alerts:
- Rate limit hits: Track how often limits are hit per endpoint
- Circuit breaker state: Alert immediately when open
- Throttle queue depth: If emails are backing up, you have a problem
- Deduplication rate: High dedup rate = double-submit bug
import { redis } from '@/lib/redis';
import { emailCircuitBreaker } from '@/lib/email-circuit-breaker';
export async function GET() {
const [circuitStatus, throttleCount] = await Promise.all([
emailCircuitBreaker.getStatus(),
redis.zcard('email:global-throttle'),
]);
return Response.json({
circuitBreaker: circuitStatus,
currentThrottleLoad: throttleCount,
timestamp: new Date().toISOString(),
});
}No Redis? In-memory alternatives
For low-volume apps or serverless environments, you can use in-memory rate limiting:
const rateLimitStore = new Map<string, number[]>();
export function checkRateLimit(
key: string,
maxRequests: number,
windowMs: number
): { allowed: boolean; retryAfter?: number } {
const now = Date.now();
const windowStart = now - windowMs;
// Get or create bucket
let timestamps = rateLimitStore.get(key) || [];
// Remove old entries
timestamps = timestamps.filter((t) => t > windowStart);
if (timestamps.length >= maxRequests) {
const oldestEntry = timestamps[0];
const retryAfter = Math.ceil((oldestEntry + windowMs - now) / 1000);
return { allowed: false, retryAfter };
}
// Add current request
timestamps.push(now);
rateLimitStore.set(key, timestamps);
// Clean up old keys periodically
if (Math.random() < 0.01) {
for (const [k, v] of rateLimitStore.entries()) {
if (v.length === 0 || v[v.length - 1] < windowStart) {
rateLimitStore.delete(k);
}
}
}
return { allowed: true };
}User-friendly error messages
When rate limits hit, don't just return 429 Too Many Requests. Tell users when they can try again:
const rateCheck = await checkUserEmailRateLimit(userId, 'password-reset', {
maxRequests: 3,
windowSeconds: 3600,
});
if (!rateCheck.allowed) {
return Response.json(
{
error: 'Rate limit exceeded',
message: `You've requested too many password resets. Please try again in ${rateCheck.retryAfter} seconds.`,
retryAfter: rateCheck.retryAfter,
},
{
status: 429,
headers: {
'Retry-After': String(rateCheck.retryAfter),
},
}
);
}Include Retry-After headers for HTTP clients. Show friendly messages in the UI.
Testing rate limits
Don't wait for production abuse to test your rate limits. Write tests:
import { checkUserEmailRateLimit } from '@/lib/rate-limiter';
import { redis } from '@/lib/redis';
describe('Email rate limiter', () => {
beforeEach(async () => {
await redis.flushdb();
});
it('allows requests under the limit', async () => {
const result1 = await checkUserEmailRateLimit('user-1', 'password-reset', {
maxRequests: 3,
windowSeconds: 60,
});
expect(result1.allowed).toBe(true);
const result2 = await checkUserEmailRateLimit('user-1', 'password-reset', {
maxRequests: 3,
windowSeconds: 60,
});
expect(result2.allowed).toBe(true);
});
it('blocks requests over the limit', async () => {
for (let i = 0; i < 3; i++) {
await checkUserEmailRateLimit('user-1', 'password-reset', {
maxRequests: 3,
windowSeconds: 60,
});
}
const result = await checkUserEmailRateLimit('user-1', 'password-reset', {
maxRequests: 3,
windowSeconds: 60,
});
expect(result.allowed).toBe(false);
expect(result.retryAfter).toBeGreaterThan(0);
});
});Rate limiting checklist
- ✅ Per-user rate limits on all email triggers
- ✅ Global throttling to respect ESP limits
- ✅ IP-based limits on public endpoints
- ✅ Deduplication for transactional emails
- ✅ Circuit breaker for ESP outages
- ✅ Monitoring dashboards and alerts
- ✅ User-friendly error messages with retry timing
- ✅ Tests for rate limit enforcement
Start simple, scale up
Don't implement all five patterns on day one. Start with per-user rate limits and deduplication. That alone stops 95% of abuse and accidents.
Add global throttling when you hit ESP limits. Add circuit breakers when you've had your first outage. Build incrementally, driven by real needs.