Handling idempotency

Ensuring that your code is idempotent is foundational to building reliable systems. Within Inngest, there are multiple ways to ensure that your functions are idempotent.

What is idempotency?

Idempotency, by definition, describes an operation that can occur multiple times without changing the result beyond the initial execution. In the world of software, this means that a functions can be executed multiple times, but it will always have the same effect as being called once. An example of this is an "upsert."

How to handle idempotency with Inngest

It should always be the aim to write code that is idempotent itself within your system or your Inngest functions, but there are also some features within Inngest that can help you ensure idempotency.

As Inngest functions are triggered by events, there are two main ways to ensure idempotency:

At the event level (the producer)

Each event that is received by Inngest will trigger any functions with that matching trigger. If an event is sent twice, Inngest will trigger the function twice. This is the default behavior as Inngest does not know if the event is the same event or a new event.

Example: Using an e-commerce store as an example, a user can add the same t-shirt to their cart twice because they want to buy two (2 unique events). That same user may check out and pay for all items in their cart but click the "pay" button twice (2 duplicate events).

To prevent an event from being handled twice, you can set a unique event id when sending the event. This id acts as an idempotency key over a 24 hour period and Inngest will check to see if that event has already been received before triggering another function.

const cartId = 'CGo5Q5ekAxilN92d27asEoDO';
await inngest.send({
  id: `checkout-completed-${cartId}`, // <-- This is the idempotency key
  name: 'cart/checkout.completed',
  data: {
    email: 'taylor@example.com',
    cartId: cartId
  }
})
Event IDTimestampFunction
checkout-completed-CGo5Q5ekAxilN92d27asEoDO08:00:00.000✅ Functions are triggered
checkout-completed-CGo5Q5ekAxilN92d27asEoDO08:00:00.248❌ Nothing is triggered

As you can see in the above example, setting the id allows you to prevent duplicate execution on the producer side, where the event originates.

Some other key points to note:

  • Event IDs will only be used to prevent duplicate execution for a 24 hour period. After 24 hours, the event will be treated as a new event and will trigger any functions with that trigger.
  • Inngest will store the second event and it will be visible in your event history, but it will not trigger any functions.
  • Events that fan-out to multiple functions will trigger each function as they normally would.

Tip - If you are using Inngest's webhook transforms, you can set the id in the transform to ensure that the event is idempotent.

At the function level (the consumer)

You might prefer to ensure idempotency at the function level or you may not be able to control the event that is being sent (from a webhook). The function's idempotency config option allows you to do this.

Each function's idempotency key is defined as a CEL expression that is evaluated with the event payload's data. The expression is used to generate a unique string key which idempotently prevents duplicate execution of the function.

Each unique expression will only trigger one function execution per 24 hour period. After 24 hours, a new event that generates the same unique expression will trigger another function execution.

Example

We'll use the same example of an e-commerce store to demonstrate how this works. We have an event here with no id set (see above), but we want to ensure that the send-checkout-email function is only triggered once for each cartId to prevent duplicate emails being sent.

Event payload

{
  "name": "cart/checkout.completed",
  "data": {
    "email": "blake@example.com",
    "cartId": "s6CIMNqIaxt503I1gVEICfwp"
  },
  "ts": 1703275661157
}

Function definition with idempotency key

export const sendEmail = inngest.createFunction(
  {
    id: 'send-checkout-email',
    // This is the idempotency key
    idempotency: 'event.data.cartId',
    // Evaluates to: "s6CIMNqIaxt503I1gVEICfwp"
    // for the given event payload
  },
  { trigger: 'cart/checkout.completed' },
  async  ({ event, step }) => { /* ... */ }
})

Writing CEL expressions

While CEL can do many things, we'll focus on how to use it to generate a unique string key for idempotency. The key things to know are:

  • You can access any of the event payload's data using the event variable and dot-notation for nested properties.
  • You can use the + operator to concatenate strings together.

Combining two or more properties together is a good way to ensure the level of uniqueness that you need. Here are couple of examples:

  • User signup: You only want to send a welcome email once per user, so you'd set idempotency to event.data.userId in case there your API sends duplicate events.
  • Organization team invite: A user may be part of multiple organizations in your app. You only want to send a team invite email once per user/organization combination, so you'd set idempotency to event.data.userId + "-" + event.data.organizationId.

For more information on writing CEL expressions, read our guide.

💡 If you want to control when a function is executed over a period of time you might prefer:

  • rateLimit - Limit the number of function executions per period of time
  • debounce - Delay function execution for duplicate events over a period of time

Idempotency keys and fan-out

One reason why you might want to use idempotency at the function level is if you have an event that fans-out to multiple functions. Let's take the following fan-out example:

FunctionEvent triggerHow often
Track requestsai/generation.requestedEvery time
Run generationai/generation.requestedOnce per request

In this case, you would want to set idempotency on the "Run generation" function to ensure that it runs once, for example, for every unique prompt that is sent. You may want to do this as you don't want to re-run the same exact prompt and waste compute resources/credits. However, you still might want to track the number of requests that each user submitted, so you would not want to set idempotency on the "Track requests" function. You can see the code for both functions below.

View the function code

Both functions use the same event trigger, ai/generation.requested which contains a promptHash and a userId in the event payload.

Track requests function

const trackRequests = inngest.createFunction(
  { id: 'track-requests' },
  { event: 'ai/generation.requested' },
  async ({ event, step }) => {
    // Track the request
  }
)

Run generation function

const runGeneration = inngest.createFunction(
  {
    id: 'run-generation',
    // Given the event payload sends a hash of the prompt,
    // this will only run once per unique prompt per user
    // every 24 hours:
    idempotency: `event.data.promptHash + "-" + event.data.userId`
  },
  { event: 'ai/generation.requested' },
  async ({ event, step }) => {
    // Track the request
  }
)