Idempotency: Running Functions Safely More Than Once
Every concept in this module has quietly relied on a single foundation that we've referenced but never fully examined. Retries, fan-out, at-least-once delivery, step memoisation — all of them are only safe because of one underlying principle.
That principle is idempotency.
It's the most important concept in reliable distributed systems, and ironically one of the least taught. Most tutorials show you how to write code that works. Idempotency is what makes that code stay correct when the inevitable happens: a network timeout causes a retry, a bug causes a replay, a user double-clicks the "Pay" button, or a deployment interrupts an in-progress run.
By the end of this article, you'll understand idempotency deeply enough to design it into your code from the start — not patch it in as an afterthought.
Quick Reference
What idempotency means: Running an operation once produces the same result as running it N times. The outcome is identical regardless of how many times the operation executes.
Why it matters: Distributed systems deliver at-least-once guarantees. Your code will run more than once. Idempotency is what makes that safe.
Four layers in Inngest:
- Step memoisation — completed steps never re-execute on retry (built-in)
- Idempotent code patterns — write operations that are safe to repeat (your responsibility)
- Event-level deduplication — set an
idoninngest.send()to prevent duplicate triggers (24h window) - Function-level idempotency key — use a CEL expression to deduplicate at the consumer (24h window)
The upsert mindset: Design every database write as an upsert or conditional insert, not a blind insert.
What You Need to Know First
Required reading (in order):
- Events, Queues, and Workers: The Building Blocks — the at-least-once delivery section
- Steps: Breaking Work into Durable Units — step memoisation
- Retries, Timeouts, and Error Handling — when functions re-run
You should understand:
- Why queues guarantee at-least-once (not exactly-once) delivery
- How
step.run()memoises results across re-executions - What happens when a function retries after partial completion
What We'll Cover in This Article
By the end of this guide, you'll understand:
- What idempotency means precisely, with examples
- Why distributed systems can't guarantee exactly-once execution
- The four layers of idempotency in Inngest and when to use each
- How to write idempotent code: upserts, conditional writes, deduplication checks
- Event-level deduplication with
idoninngest.send() - Function-level idempotency keys via the
idempotencyconfig option - The 24-hour window limitation and how to reason about it
- Idempotency for external API calls: using idempotency keys with Stripe et al.
What We'll Explain Along the Way
- The "at-least-once vs exactly-once" distinction
- Why step memoisation handles some — but not all — idempotency concerns
- Upserts (INSERT OR UPDATE) as the default mindset for database writes
- What a "natural idempotency key" is and how to find one in your data
Part 1: What Idempotency Actually Means
Let's start with the precise definition, then make it concrete.
An operation is idempotent if applying it multiple times produces the same result as applying it once.
The classic mathematical example is multiplication by 1: 5 × 1 × 1 × 1 = 5. No matter how many times you multiply by 1, the result doesn't change.
In software, the clearest example is an HTTP PUT request. If you PUT /users/123 with the same body three times, the user's record ends up in the same state as if you'd done it once. Contrast this with POST /users, which creates a new user each time — run it three times and you have three users.
Here's the software version of that distinction:
// ❌ Not idempotent — creates a new record each time
async function createSubscription(userId: string, plan: string) {
return await db.subscriptions.insert({ userId, plan, status: "active" });
// Run twice → two subscription records for the same user
}
// ✅ Idempotent — ensures exactly one record exists regardless of how many times it runs
async function ensureSubscription(userId: string, plan: string) {
return await db.subscriptions.upsert(
{ userId }, // find by this
{ userId, plan, status: "active" }, // insert or update with this
);
// Run twice → same single subscription record
}
This is the core of idempotency: replacing operations that accumulate with operations that converge to the same state.
Part 2: Why Exactly-Once Is a Myth in Distributed Systems
Here's the uncomfortable truth that every distributed systems engineer eventually accepts: you cannot guarantee that your code runs exactly once in a distributed environment.
Networks time out. Servers restart. Acknowledgements get lost in transit. Deployments interrupt in-progress work. All of these cause retries — and retries mean your function might run again.
We covered the acknowledgement gap in Article 2: a worker completes its work, is about to send an acknowledgement to the queue, and crashes. The queue never receives the acknowledgement. It re-queues the job. Another worker picks it up. Your function runs a second time.
Inngest's step memoisation eliminates most of this risk by design — completed steps don't re-execute on retry. But memoisation doesn't cover every scenario:
Scenario A: Step fails and retries → memoisation protects earlier steps ✅
Scenario B: The same event is sent twice from your producer code →
two function runs start from scratch, each with their own step state ❌
(memoisation doesn't help — these are two separate run instances)
Scenario C: A developer manually replays a run from the Inngest dashboard →
a new run starts, steps execute again ❌
Scenario D: A webhook from a third party fires twice due to their retry logic →
two events arrive, two function runs start ❌
For scenarios B, C, and D, you need idempotency at the code level — writing operations that are safe to repeat.
As Inngest's own error handling documentation states directly: "Re-running a step upon error requires its code to be idempotent, which means that running the same code multiple times won't have any side effect. For example, a step inserting a new user to the database is not idempotent while a step upserting a user is."
Part 3: Layer 1 — Step Memoisation (Built-In)
The first layer of idempotency is built into Inngest and requires no work from you. When a function retries, completed steps are skipped — their saved results are replayed. This means that within a single function run, each step's side effects happen exactly once regardless of how many times the function handler is called.
export const processPayment = inngest.createFunction(
{ id: "process-payment", retries: 3 },
{ event: "payment/requested" },
async ({ event, step }) => {
// Step 1: Charge the card
// If this succeeds, its result is memoised.
// If a later step fails and the function retries:
// → this step is SKIPPED, saved result replayed
// → the card is NOT charged again ✅
const charge = await step.run("charge-card", async () => {
return await stripe.charges.create({
amount: event.data.amountCents,
currency: "usd",
source: event.data.paymentToken,
});
});
// Step 2: Record the charge
// If this fails, step 1 doesn't re-run on retry ✅
await step.run("record-charge", async () => {
await db.payments.create({
userId: event.data.userId,
chargeId: charge.id,
amount: event.data.amountCents,
});
});
},
);
Step memoisation handles the most critical retry scenario: partial function completion. It's automatic, reliable, and covers the common case perfectly.
What it doesn't cover: two separate invocations of the same function (two separate runs with separate step states). For that, you need the layers below.
Part 4: Layer 2 — Idempotent Code Patterns
The second layer is writing code that's intrinsically safe to run multiple times — not relying on memoisation to protect you.
The upsert mindset
Replace blind inserts with upserts (insert-or-update) wherever a record should logically exist at most once.
// ❌ Blind insert — running twice creates two records
await step.run("create-user-record", async () => {
await db.users.insert({
id: event.data.userId,
email: event.data.email,
createdAt: new Date().toISOString(),
});
});
// ✅ Upsert — running twice leaves exactly one record
await step.run("ensure-user-record", async () => {
await db.users.upsert(
{ id: event.data.userId }, // conflict target
{
email: event.data.email,
updatedAt: new Date().toISOString(),
}, // update on conflict
{ createFields: { createdAt: new Date().toISOString() } }, // only on first insert
);
});
In SQL terms, this is INSERT ... ON CONFLICT DO UPDATE. In ORMs, it's often called upsert() or createOrUpdate(). In MongoDB, it's findOneAndUpdate with upsert: true.
Conditional writes with existence checks
When upsert isn't available or appropriate, use existence checks:
await step.run("create-stripe-customer", async () => {
// Check first — don't create if one already exists
const existing = await stripe.customers.list({
email: event.data.email,
limit: 1,
});
if (existing.data.length > 0) {
// Customer already exists — return the existing one
return { customerId: existing.data[0].id, wasCreated: false };
}
// Safe to create — we verified they don't exist
const customer = await stripe.customers.create({
email: event.data.email,
metadata: { userId: event.data.userId },
});
return { customerId: customer.id, wasCreated: true };
});
Using natural idempotency keys
Many external APIs accept an idempotency key — a unique string you supply with the request. If you send the same request twice with the same key, the API returns the cached result from the first request rather than executing again.
Stripe is the canonical example:
await step.run("create-payment-intent", async () => {
return await stripe.paymentIntents.create(
{
amount: event.data.amountCents,
currency: "usd",
customer: event.data.stripeCustomerId,
},
{
// Idempotency key: unique to this specific payment attempt
// event.id is Inngest's unique event identifier — perfect for this
idempotencyKey: `payment-intent-${event.id}`,
},
);
});
Using event.id as part of your idempotency key is a natural fit — every Inngest event has a unique id, so payment-intent-${event.id} will be the same key on every retry of this step (since the event doesn't change), but different for every distinct payment event.
This is Inngest's recommended approach for idempotency at the step level — write code that is itself idempotent rather than relying solely on platform features.
Deduplication via a processed-events table
For critical side effects that have no built-in idempotency key support, maintain a record of what's been done:
await step.run("send-contract-signed-notification", async () => {
// Check if we've already sent this notification
const alreadySent = await db.sentNotifications.findOne({
type: "contract-signed",
contractId: event.data.contractId,
});
if (alreadySent) {
console.log(
`Notification already sent for contract ${event.data.contractId}, skipping`,
);
return { status: "already-sent", sentAt: alreadySent.sentAt };
}
// Send the notification
const result = await emailService.send({
to: event.data.signerEmail,
template: "contract-signed",
data: { contractId: event.data.contractId },
});
// Record that we've sent it — next run will find this and skip
await db.sentNotifications.create({
type: "contract-signed",
contractId: event.data.contractId,
sentAt: new Date().toISOString(),
messageId: result.messageId,
});
return { status: "sent", messageId: result.messageId };
});
As Svix's idempotency guide notes, this "processed events table" pattern is the standard approach for webhook handlers: check if the event ID exists before processing, insert it before doing work, and skip if it's already there.
Part 5: Layer 3 — Event-Level Deduplication
The third layer operates before your function even starts. When you send an event to Inngest, you can include an id field. If Inngest receives two events with the same id within 24 hours, it stores the second one in the event log but does not trigger any functions for it.
// Without event ID — each send triggers a new function run
await inngest.send({
name: "cart/checkout.completed",
data: { cartId: "abc123", email: "user@example.com" },
});
// Send this twice → two function runs 😬
// With event ID — the second send is a no-op within 24 hours
await inngest.send({
id: `checkout-completed-${cartId}`, // ← idempotency key
name: "cart/checkout.completed",
data: { cartId: "abc123", email: "user@example.com" },
});
// Send this twice → one function run ✅
This is the right tool when:
- Double-submit protection — a user clicks "Pay" twice, your API fires the event twice
- Webhook deduplication — a third-party webhook retries and sends the same event again
- Retry logic in your own code — your API route might retry
inngest.send()on network error
Designing a good event ID
The event ID must be unique to the semantic event — not just technically unique. Two different checkout completions for the same cart must have the same ID (they're the same event, just submitted twice). Two different checkouts for different carts must have different IDs.
// ✅ Good — specific to this cart's checkout, not globally unique per send
id: `checkout-completed-${cartId}`;
// ✅ Good — specific to this user's account creation
id: `account-created-${userId}`;
// ✅ Good — combines event type + entity ID to be globally distinct
id: `invoice-paid-${invoiceId}`;
// ❌ Bad — too generic, would collide across different customers
id: cartId; // "abc123" could be used by both checkout and item-deleted events
// ❌ Bad — too unique, defeats the purpose
id: `checkout-${Date.now()}`; // always different, never deduplicates
As Inngest's sending events documentation warns: "The id is global across all event types, so make sure your id isn't a value that will be shared across different event types." Prefixing with the event name (or an abbreviation of it) is the safe convention.
The 24-hour window
Event deduplication only prevents duplicate triggers for 24 hours from the first event. After 24 hours, a new event with the same ID is treated as fresh and will trigger functions normally.
This is by design — you generally don't want a payment event from last week to silently suppress a legitimate new payment today. The 24-hour window covers the realistic window for double-submits and webhook retries.
// Timeline example:
// t=0h → send event with id="checkout-completed-abc123" → function triggered ✅
// t=0h → send same event again (user double-clicked) → SUPPRESSED ✅
// t=25h → send same event again → function triggered again ✅
// (24h window expired — treated as new event)
Part 6: Layer 4 — Function-Level Idempotency Keys
Sometimes you can't control the event producer — you're consuming a webhook from a third party that sends the same event twice. Or you're building a fan-out system and want to guarantee that a specific function only runs once per entity per time window, regardless of how many times the event is fired.
This is where the function-level idempotency config option comes in. It's a CEL expression evaluated against the event payload. If the expression produces the same key as a run within the past 24 hours, the new run is suppressed.
export const sendCheckoutEmail = inngest.createFunction(
{
id: "send-checkout-email",
// This function will only run once per cartId per 24 hours
// Even if "cart/checkout.completed" is fired 5 times for the same cart
idempotency: "event.data.cartId",
},
{ event: "cart/checkout.completed" },
async ({ event, step }) => {
await step.run("send-email", async () => {
await emailService.sendOrderConfirmation({
to: event.data.email,
cartId: event.data.cartId,
});
});
},
);
This is evaluated as a CEL expression: event.data.cartId evaluates to the string value of cartId in the event payload (e.g., "abc123"). If another cart/checkout.completed event with cartId: "abc123" arrives within 24 hours, this function won't run again for it.
More complex idempotency expressions
You can use CEL to construct composite keys:
// Only run once per userId + plan combination per 24 hours
idempotency: "event.data.userId + '-' + event.data.plan";
// Evaluates to: "usr_123-pro" — unique to this user upgrading to this specific plan
// Only run once per order + notification type per 24 hours
idempotency: "event.data.orderId + '-' + event.data.notificationType";
// Evaluates to: "ord_456-shipping-update"
Event-level vs function-level: which to use?
Event-level (id on send) | Function-level (idempotency config) | |
|---|---|---|
| Where it stops duplicates | Before any function runs | Only this specific function |
| Who controls it | The producer (sender) | The consumer (function) |
| When to use | You control the event producer | You don't control the event source |
| Scope | All functions triggered by the event | Just this one function |
| Example | Your API route deduplicates its own sends | Consuming a third-party webhook |
Use event-level deduplication when you control the code sending the event. Use function-level when you're consuming events from external sources, or when different functions triggered by the same event need different deduplication logic.
They can also be combined — event-level deduplication as a first line of defence, function-level as a fallback:
// Producer: deduplicates at the event level
await inngest.send({
id: `checkout-completed-${cartId}`,
name: "cart/checkout.completed",
data: { cartId, email },
});
// Consumer: also deduplicates at the function level — belt and suspenders
export const sendConfirmationEmail = inngest.createFunction(
{
id: "send-confirmation-email",
idempotency: "event.data.cartId", // additional protection
},
{ event: "cart/checkout.completed" },
async ({ event, step }) => {
/* ... */
},
);
Part 7: The Complete Picture — All Four Layers
Let's trace a realistic scenario through all four layers to see how they interact.
Scenario: Your app processes subscription upgrades. A user clicks "Upgrade to Pro" and your frontend accidentally fires two POST requests due to a network retry. Both requests reach your backend and both call inngest.send().
Layer 1: Step memoisation
─────────────────────────
Both sends create two separate function runs (if layer 3/4 don't catch them).
Within each run, step memoisation ensures that completed steps don't repeat
on retry within that run. A card charge in run A won't double-charge due to
a failure later in run A. But runs A and B are independent — they can both
charge the card separately.
Layer 2: Idempotent code
────────────────────────
Inside both runs, the Stripe charge uses event.id as an idempotency key.
Because the two events have different IDs (each inngest.send() call creates
a new event with a unique auto-generated ID), Stripe will not deduplicate.
Two charges could still occur.
Layer 3: Event-level deduplication ← this is the fix
──────────────────────────────────
You set `id: "subscription-upgraded-${userId}"` on inngest.send().
The second send is suppressed — only one function run starts.
One charge.
Layer 4: Function-level idempotency ← belt and suspenders
────────────────────────────────────
Even if you couldn't control the producer, setting:
`idempotency: "event.data.userId + '-upgrade'"` on the function would
prevent the second function from running. Belt and suspenders.
The code with all layers applied:
// ── Producer: API route ────────────────────────────────────────────────────
export async function POST(request: Request) {
const { userId, newPlan } = await request.json();
await inngest.send({
// Layer 3: event-level deduplication
// If this exact upgrade fires twice, only the first triggers a function
id: `subscription-upgraded-${userId}-${newPlan}`,
name: "subscription/upgraded",
data: { userId, newPlan, requestedAt: new Date().toISOString() },
});
return Response.json({ status: "upgrade queued" });
}
// ── Consumer: Inngest function ─────────────────────────────────────────────
export const handleSubscriptionUpgrade = inngest.createFunction(
{
id: "handle-subscription-upgrade",
retries: 3,
// Layer 4: function-level idempotency (belt and suspenders)
idempotency: "event.data.userId + '-' + event.data.newPlan",
},
{ event: "subscription/upgraded" },
async ({ event, step }) => {
// Layer 1: step memoisation (automatic)
// If this step succeeds, it won't re-run on retry within this run
// Layer 2: idempotent code — upsert instead of insert
const subscription = await step.run(
"update-subscription-record",
async () => {
return await db.subscriptions.upsert(
{ userId: event.data.userId }, // conflict target
{ plan: event.data.newPlan, upgradedAt: new Date().toISOString() },
);
},
);
// Layer 2: idempotency key on external API call
await step.run("create-stripe-subscription", async () => {
return await stripe.subscriptions.create(
{
customer: subscription.stripeCustomerId,
items: [{ price: getPriceIdForPlan(event.data.newPlan) }],
},
{
// event.id is unique per event — same key on every retry of this run
idempotencyKey: `stripe-sub-${event.id}`,
},
);
});
await step.run("send-upgrade-confirmation", async () => {
await emailService.sendUpgradeConfirmation({
userId: event.data.userId,
newPlan: event.data.newPlan,
});
});
},
);
Part 8: Idempotency for Emails — The Special Case
Email deserves its own section because it's where idempotency failures are most visible to users. A duplicate charge might go unnoticed for days. A duplicate "Welcome to Pro!" email is noticed immediately.
Several strategies apply:
Use a sent-log table
await step.run("send-upgrade-email", async () => {
// Check before sending
const alreadySent = await db.emailLog.findOne({
userId: event.data.userId,
template: "subscription-upgrade",
// Only check within a reasonable window to avoid blocking legitimate
// future emails of the same type
sentAfter: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
});
if (alreadySent) {
return { status: "skipped", reason: "already sent within 24h" };
}
const result = await emailService.sendUpgradeConfirmation({
userId: event.data.userId,
newPlan: event.data.newPlan,
});
// Record the send within the same operation
await db.emailLog.create({
userId: event.data.userId,
template: "subscription-upgrade",
messageId: result.messageId,
sentAt: new Date().toISOString(),
});
return { status: "sent", messageId: result.messageId };
});
Use function-level idempotency for email functions
If your email function is only ever responsible for one type of email triggered by one event, the function-level idempotency key is the cleanest solution:
export const sendUpgradeEmail = inngest.createFunction(
{
id: "send-upgrade-email",
// One email per userId+plan combination per 24 hours
idempotency: "event.data.userId + '-' + event.data.newPlan",
},
{ event: "subscription/upgraded" },
async ({ event, step }) => {
await step.run("send", async () => {
await emailService.sendUpgradeConfirmation(event.data);
});
},
);
Use email provider idempotency keys
Some email providers (Resend, Postmark, SendGrid) support their own idempotency keys on the send API. Check your provider's documentation — this is the strongest guarantee since it operates at the provider level rather than relying on your database.
Common Misconceptions
❌ Misconception: Step memoisation makes all your code idempotent
Reality: Step memoisation protects against retry-within-a-run for steps that completed. It does not protect against two separate function runs executing the same code with separate step states. If two events with different IDs both trigger your function, both runs start from scratch with empty step states. You need idempotent code patterns or event/function-level deduplication for this case.
❌ Misconception: Using id on inngest.send() guarantees exactly-once execution forever
Reality: Event IDs only prevent duplicate execution for a 24-hour period. After 24 hours, the same event ID is treated as fresh and will trigger functions normally. This is intentional — a payment event from last week shouldn't suppress a new legitimate payment today. Design your idempotency windows to match your business logic.
❌ Misconception: Idempotency only matters for payment operations
Reality: Idempotency matters for any operation with observable side effects: database writes, emails, SMS messages, webhook calls to external services, file uploads, cache updates. Even "minor" operations like incrementing a counter (UPDATE stats SET count = count + 1) are not idempotent — run twice and the count is incremented twice. Use SET count = ? with the final expected value instead.
❌ Misconception: An upsert is always the right fix for non-idempotent inserts
Reality: Upserts work when "insert or update to the same state" is the correct semantic. But sometimes you genuinely want to know whether a record was just created vs. already existed — and take different actions in each case. In those scenarios, use a findOrCreate pattern that returns both the record and a boolean wasCreated flag, then branch your logic accordingly.
Troubleshooting Common Issues
Problem: Duplicate records appearing in your database despite retries using step memoisation
Diagnosis: You have two separate function runs (not one run retrying), each with their own step state. Both ran the insert step and both inserted a record.
Fix: Switch to upsert, or add event-level / function-level deduplication to prevent duplicate function runs from starting.
// Verify with the Dev Server: look at the Runs tab.
// If you see TWO run entries for the same event, that's the cause.
// If you see ONE run entry with multiple step attempts, that's a retry —
// step memoisation should already protect you.
Problem: Event-level deduplication isn't working — the function runs twice
Diagnostic steps:
// 1. Check that you're setting `id` on BOTH sends, not just one
await inngest.send({ id: "my-key", name: "...", data: {} }); // ✅ has id
await inngest.send({ name: "...", data: {} }); // ❌ no id → not deduplicated
// 2. Check that both IDs are actually the same string
// Log them before sending to verify
const eventId = `checkout-completed-${cartId}`;
console.log("Sending event with id:", eventId); // must be identical on both sends
await inngest.send({ id: eventId, ... });
Common cause: The ID is computed differently on each send (e.g., includes a timestamp) or one of the sends doesn't include an id at all.
Problem: 24-hour deduplication window is suppressing legitimate events
Scenario: Your users can legitimately perform the same action twice in a day (e.g., make two separate payments), but your event ID is based only on userId — so the second payment is silently suppressed.
Fix: Include more specificity in the event ID:
// ❌ Too broad — suppresses any second payment from this user in 24h
id: `payment-${userId}`;
// ✅ Specific to this exact payment intent
id: `payment-${userId}-${paymentIntentId}`;
// or
id: `payment-${userId}-${timestamp}`;
Check Your Understanding
Quick Quiz
1. You have a step that sends an email. The step succeeds, but the next step fails and the function retries. Does Inngest send the email again?
Show Answer
No — step memoisation prevents it. The email step is already marked as completed with its result saved. On retry, Inngest skips the email step and replays its saved result without re-executing the callback. The email is sent exactly once per function run, regardless of how many times the function retries.
Important caveat: This only applies within a single function run. If the same event somehow triggers two separate function runs (e.g., because inngest.send() was called twice without an id), both runs would execute the email step independently.
2. A Stripe webhook fires twice for the same payment (common in production). Both arrive within seconds of each other. What's the best way to ensure your Inngest function only processes it once?
Show Answer
Use function-level idempotency keyed on the Stripe event ID:
export const handleStripePayment = inngest.createFunction(
{
id: "handle-stripe-payment",
// Stripe's event ID is unique per event — perfect idempotency key
idempotency: "event.data.stripeEventId",
},
{ event: "stripe/payment.succeeded" },
async ({ event, step }) => {
/* ... */
},
);
Since you don't control Stripe's webhook delivery (they'll retry on failure), you can't use event-level deduplication (which requires you to set the id when sending). Function-level idempotency is the right layer here — it deduplicates at the consumer without requiring any change to how events are sent.
3. What's the difference between these two database operations, and which is idempotent?
// Option A
await db.users.insert({ id: userId, email, plan: "pro" });
// Option B
await db.users.upsert(
{ id: userId },
{ email, plan: "pro", updatedAt: new Date().toISOString() },
);
Show Answer
Option A (insert) is not idempotent. Running it twice throws a unique constraint error (or creates a duplicate if there's no constraint). The second execution has a different effect from the first.
Option B (upsert) is idempotent. Running it twice leaves the database in the same state — one user record with plan: "pro". The second execution produces the same observable result as the first. (Note: updatedAt will be a slightly different timestamp, but the meaningful state — the user's plan — is identical.)
Hands-On Challenge
You're building a function that handles user/subscription.cancelled. It needs to:
- Update the user's subscription status to
"cancelled"in the database - Revoke the user's access in your permissions service
- Send a cancellation confirmation email
- Create a "win-back" follow-up task due in 7 days
The function might run twice for the same cancellation (duplicate webhook). Make all four steps idempotent.
See a Suggested Solution
import { NonRetriableError } from "inngest";
export const handleSubscriptionCancelled = inngest.createFunction(
{
id: "handle-subscription-cancelled",
retries: 3,
// Function-level deduplication: only run once per subscription per 24h
idempotency: "event.data.subscriptionId",
},
{ event: "user/subscription.cancelled" },
async ({ event, step }) => {
// Step 1: Upsert the cancellation status — idempotent by nature
const subscription = await step.run(
"update-subscription-status",
async () => {
return await db.subscriptions.upsert(
{ id: event.data.subscriptionId },
{
status: "cancelled",
cancelledAt: event.data.cancelledAt,
cancellationReason: event.data.reason,
},
);
},
);
if (!subscription) {
throw new NonRetriableError(
`Subscription ${event.data.subscriptionId} not found`,
);
}
// Step 2: Revoke access — most permissions services are idempotent for revoke
// (revoking already-revoked access is a no-op)
await step.run("revoke-access", async () => {
await permissionsService.revoke({
userId: event.data.userId,
// Using event.id ensures the same revocation key on every retry
idempotencyKey: `revoke-${event.id}`,
});
});
// Step 3: Send cancellation email — check sent log to prevent duplicates
await step.run("send-cancellation-email", async () => {
const alreadySent = await db.emailLog.findOne({
userId: event.data.userId,
template: "subscription-cancelled",
subscriptionId: event.data.subscriptionId,
});
if (alreadySent) return { status: "already-sent" };
const result = await emailService.sendCancellationConfirmation({
to: event.data.userEmail,
subscriptionId: event.data.subscriptionId,
});
await db.emailLog.create({
userId: event.data.userId,
template: "subscription-cancelled",
subscriptionId: event.data.subscriptionId,
messageId: result.messageId,
sentAt: new Date().toISOString(),
});
return { status: "sent", messageId: result.messageId };
});
// Step 4: Create win-back task — upsert to avoid duplicate tasks
await step.run("create-winback-task", async () => {
const dueDate = new Date();
dueDate.setDate(dueDate.getDate() + 7);
await db.tasks.upsert(
{
type: "winback",
userId: event.data.userId,
subscriptionId: event.data.subscriptionId,
},
{
dueAt: dueDate.toISOString(),
status: "pending",
createdAt: new Date().toISOString(),
},
);
});
return {
status: "cancellation processed",
subscriptionId: event.data.subscriptionId,
};
},
);
Key decisions:
- Function-level
idempotencyis the first line of defence — prevents duplicate runs entirely - All database writes use upsert with a meaningful conflict target
- Email uses a sent-log check with both
userIdandsubscriptionIdin the lookup — prevents duplicates even if the function-level key expires - External API calls use
event.id-based idempotency keys
Summary: Key Takeaways
- Idempotency means running an operation multiple times has the same effect as running it once. It's the foundation that makes retries, replays, and at-least-once delivery safe.
- Exactly-once execution is a myth in distributed systems. Plan for your code to run more than once.
- Four layers in Inngest:
- Step memoisation (Layer 1): automatic, protects within a single run
- Idempotent code patterns (Layer 2): upserts, conditional writes, external API idempotency keys
- Event-level
id(Layer 3): set oninngest.send(), 24h deduplication window - Function
idempotencykey (Layer 4): CEL expression, 24h window, consumer-side
- The upsert mindset: default to upsert for every database write where a record should exist at most once per entity.
- Idempotency keys on external APIs: use
event.idto construct unique-but-stable keys for Stripe and other services that support them. - Choose the right layer: event-level when you control the producer; function-level when consuming external webhooks or when different functions need different deduplication logic.
- The 24-hour window: both event-level and function-level deduplication operate within a 24-hour window. After expiry, the same key is treated as fresh.
What's Next?
You now have a complete understanding of idempotency — the principle that ties together every resilience feature in this module.
In Article 11: Local Development with the Inngest Dev Server (coming soon), we step back from concepts and get practical about the development experience. You'll explore every corner of the Dev Server dashboard: inspecting events, reading run traces, replaying failures, testing in isolation, and setting up your local environment to match production as closely as possible.
Version Information
Tested with:
inngest:^4.1.x- Node.js: v18.x, v20.x, v22.x
- TypeScript: 5.x
Deduplication window: 24 hours for both event-level (id) and function-level (idempotency) deduplication.
Further reading:
- Handling Idempotency — Inngest Documentation — official guide covering event-level and function-level approaches
- Sending Events — Inngest Documentation —
idfield reference and deduplication behaviour - Errors & Retries — Inngest Documentation — idempotency in the context of step retries
- Idempotency Keys — Stripe Documentation — canonical example of API-level idempotency keys
- Idempotency and Deduplication — Svix — webhook-specific idempotency patterns