Preventing duplicate transactional emails on serverless timeouts and client retries using idempotency keys

In serverless environments like Next.js API routes or AWS Lambda, transient network hiccups or client-side form double-clicks can cause duplicate transactional emails (e.g. two purchase receipts or duplicate OTP codes). What is the standard architectural pattern to enforce idempotency and avoid duplicate dispatches?

Preventing Duplicate Transactional Emails in Serverless Workflows

Duplicate emails typically occur when:

  1. A client double-submits a form before the initial HTTP request completes.
  2. A serverless function encounters a network delay, the frontend or worker times out and retries, but the initial API call actually succeeded on the provider side.

Architectural Pattern: Idempotency Keys + Atomic Locking

To guarantee exactly-once email dispatch, implement an idempotency lock using an ephemeral key-value store (Upstash Redis, Cloudflare KV, or DynamoDB) before invoking the email API.

// app/api/send-invoice/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

export async function POST(req: NextRequest) {
  const { orderId, email, amount } = await req.json();
  const idempotencyKey = `email_lock:order_${orderId}`;

  // 1. Acquire atomic lock with 24-hour expiration (NX = Only set if Not eXists)
  const isFirstAttempt = await redis.set(idempotencyKey, "pending", {
    nx: true,
    ex: 86400, // 24 hours
  });

  if (!isFirstAttempt) {
    // Duplicate request detected — return cached success to prevent double-send
    return NextResponse.json({
      success: true,
      message: "Invoice email already dispatched for this order.",
    });
  }

  try {
    // 2. Dispatch email via AutoSend API
    const response = await fetch("https://api.autosend.com/v1/mails/send", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${process.env.AUTOSEND_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        from: { email: "billing@yourdomain.com", name: "Billing" },
        to: { email },
        subject: `Receipt for Order #${orderId}`,
        templateId: "tpl_receipt_v1",
        dynamicData: { orderId, amount },
      }),
    });

    if (!response.ok) {
      // If API fails, release lock so legitimate retries can proceed
      await redis.del(idempotencyKey);
      const err = await response.json();
      return NextResponse.json(err, { status: response.status });
    }

    // Mark status as completed
    await redis.set(idempotencyKey, "completed", { ex: 86400 });
    return NextResponse.json({ success: true });
  } catch (error) {
    await redis.del(idempotencyKey);
    return NextResponse.json({ error: (error as Error).message }, { status: 500 });
  }
}

Best Practices:

  • Scope Keys to Business Entities: Derive keys from stable business events (e.g. order_id, invoice_id, user_signup_id), never random UUIDs generated on each retry.
  • AutoSend Batch API for Bulk Transactions: For high-volume transaction spikes (e.g. monthly billing runs), use the bulk send endpoint POST /v1/mails/bulk to deliver up to 500 personalized receipts in a single HTTP payload.

Relevant Documentation