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:
- A client double-submits a form before the initial HTTP request completes.
- 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/bulkto deliver up to 500 personalized receipts in a single HTTP payload.