Code Tips15 min read

Email Rate Limiting & Throttling: 5 Production Patterns That Prevent Abuse and ESP Suspensions

Stop email abuse, accidents, and ESP suspensions with production-ready rate limiting. Per-user limits, global throttling, IP-based protection, deduplication, and circuit breakers that prevent cascading failures.

R

React Emails Pro

March 3, 2026

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.

The problem: Without rate limiting, email systems become attack vectors. ESPs suspend your account. Users get spammed. Your sender reputation tanks.

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.

lib/rate-limiter.ts
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:

app/api/auth/reset-password/route.ts
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...
}
Common limits:
  • 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.

lib/email-throttle.ts
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:

lib/email-queue.ts
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);
});
Safe margins: If your ESP allows 10/sec, throttle at 8/sec. Network latency and retry logic can cause bursts. Leave headroom.

3) IP-based rate limiting: Stop attackers

Anonymous endpoints (password reset, contact forms) need IP-based limits to prevent brute force and abuse.

lib/ip-rate-limiter.ts
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:

app/api/contact/route.ts
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...
}
Cloudflare note: If you're behind Cloudflare, use 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.

lib/email-deduplication.ts
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:

lib/send-email.ts
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 };
}
Window sizing:
  • 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.

lib/email-circuit-breaker.ts
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:

lib/email-sender.ts
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;
  }
}
Recovery: Circuit breakers need manual monitoring. Set up alerts when the circuit opens. Check ESP status, fix issues, then manually reset the breaker (or wait for auto-timeout).

Production setup: All patterns together

Here's what a fully protected email send flow looks like, combining all five patterns:

lib/protected-send-email.ts
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
app/api/admin/email-metrics/route.ts
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(),
  });
}
Grafana dashboard: Graph rate limit hits, circuit breaker state, and send throughput on a single dashboard. You'll spot patterns (abuse, bugs, ESP issues) at a glance.

No Redis? In-memory alternatives

For low-volume apps or serverless environments, you can use in-memory rate limiting:

lib/in-memory-rate-limiter.ts
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 };
}
Limitation: In-memory rate limiting doesn't work across multiple servers. If you scale horizontally, you need Redis or a distributed rate limiter.

User-friendly error messages

When rate limits hit, don't just return 429 Too Many Requests. Tell users when they can try again:

app/api/auth/reset/route.ts
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:

tests/rate-limiter.test.ts
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

Before you ship:
  • ✅ 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.

Quick win: Add per-user rate limits to password reset today. It's 20 lines of code and prevents the most common email abuse vector.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates