Multi-Step Functions

Use Inngest's multi-step functions to safely coordinate between events, delay execution for hours or days, and conditionally run code based on the result of previous steps and incoming events.

Critically, multi-step functions are written in code, not config, meaning you create readable, obvious functionality that's easy to maintain.

Benefits

Writing multi-step functions provide you with some easy-to-use tools to create intuitive flows for your system.

  • Run retryable blocks of code to maximum reliability
  • Pause execution and Wait for an event matching rules before continuing
  • Pause for an amount of time or until a specified time

This makes building reliable, distributed code simple. By wrapping asynchronous actions such as API calls in retryable blocks, we can ensure reliability when coordinating across many services.

Writing

Multi-step functions are written using the createFunction() method on the Inngest client.

First, let's look at a simple single-step function.

import { Inngest } from "inngest";

const inngest = new Inngest({ id: "my-app" });

export default inngest.createFunction(
  { id: "activation-email" },
  { event: "app/user.created" },
  async ({ event }) => {
    await sendEmail({ email: event.user.email, template: "welcome" });
  }
);

This function will send a user an email when they sign up. Nice and simple.

We have a new requirement, though, that we should send the user another email if they haven't created a post on our platform within 24 hours of signing up. We have a app/post.created event that is fired when this happens, so we can use that (or here, the absence of that) to trigger the second email.

First, let's convert our function to a multi-step function. To do this, we will:

  • Add a new step argument to your function
  • Wrap our sendEmail() call in a step.run() call
export default inngest.createFunction(
  { id: "activation-email" },
  { event: "app/user.created" },
  async ({ event, step }) => {
    await step.run("send-welcome-email", async () => {
      return await sendEmail({ email: event.user.email, template: "welcome" });
    });
  }
);

Great! Now we have a multi-step function.

The main difference is that we've wrapped our sendEmail() call in a step.run() call. This is how we tell Inngest that this is an individual step in our function. This step can be retried independently, just like a single-step function would.

Once our welcome email is sent, we want to wait at most 24 hours for our user to create a post. If they haven't created one by then, we want to send them a reminder email.

To do this, we can use the waitForEvent step tool. This tool will wait for a matching event to be fired, and then return the event data. If the event is not fired within the timeout, it will return null, which we can use to decide whether to send the reminder email.

export default inngest.createFunction(
  { id: "activation-email" },
  { event: "app/user.created" },
  async ({ event, step }) => {
    await step.run("send-welcome-email", async () => {
      return await sendEmail({ email: event.user.email, template: "welcome" });
    });

    // Wait for an "app/post.created" event
    const postCreated = await step.waitForEvent("wait-for-post-creation", {
      event: "app/post.created",
      match: "data.user.id", // the field "data.user.id" must match
      timeout: "24h", // wait at most 24 hours
    });
  }
);

Now we have our postCreated variable, which will be null if the user hasn't created a post within 24 hours, or the event data if they have.

Finally, we can use this to send the reminder email if the user hasn't created a post by running another block of code with step.run().

export default inngest.createFunction(
  { id: "activation-email" },
  { event: "app/user.created" },
  async ({ event, step }) => {
    await step.run("send-welcome-email", async () => {
      return await sendEmail({ email: event.user.email, template: "welcome" });
    });

    // Wait for an "app/post.created" event
    const postCreated = await step.waitForEvent("wait-for-post-creation", {
      event: "app/post.created",
      match: "data.user.id", // the field "data.user.id" must match
      timeout: "24h", // wait at most 24 hours
    });

    if (!postCreated) {
      // If no post was created, send a reminder email
      await step.run("send-reminder-email", async () => {
        return await sendEmail({
          email: event.user.email,
          template: "reminder",
        });
      });
    }
  }
);

That's it! We've now written a multi-step function that will send a welcome email, and then send a reminder email if the user hasn't created a post within 24 hours.

Most importantly, we had to write no config to do this. We can use all the power of JavaScript to write our functions and all the power of Inngest's tools to coordinate between events and steps.

Step Reference

For an in-depth reference of each step tool that you can use read our create function reference or jump directly to a step reference guide:

  • step.run() - Run synchronous or asynchronous code as a retryable step in your function
  • step.sleep() - Sleep for a given amount of time
  • step.sleepUntil() - Sleep until a given time
  • step.invoke() - Invoke another Inngest function as a step, receiving the result of the invoked function
  • step.waitForEvent() - Pause a function's execution until another event is received
  • step.sendEvent() - Send event(s) reliability within your function. Use this instead of inngest.send() to ensure reliable event delivery from within functions.

Gotchas

My function is running twice

Inngest will communicate with your function multiple times throughout a single run and will use your use of tools to intelligently memoize state.

For this reason, placing business logic outside of a step.run() call is a bad idea, as this will be run every time Inngest communicates with your function.

I want to run asynchronous code

step.run() accepts an async function, like so:

await step.run("do-something", async () => {
  // your code
});

Each call to step.run() is a single retryable step - a lightweight transaction. Therefore, each step should have a single side effect. For example, the below code is problematic:

await step.run("create-alert", async () => {
  const alertId = await createAlert();
  await sendAlertLinkToSlack(alertId);
});

If createAlert() succeeds but sendAlertLinkToSlack() fails, the code will be retried and an alert will be created every time the step is retried.

Instead, we should split out asynchronous actions into multiple steps so they're retried independently.

const alertId = await step.run("create-alert", () => createAlert());

await step.run("send-alert-link", () => sendAlertLinkToSlack(alertId));

My variable isn't updating

Because Inngest communicates with your function multiple times, memoising state as it goes, code within calls to step.run() is not called on every invocation.

This can be confusing if you're using steps to update variables within the function's closure, like so:

// THIS IS WRONG!  step.run only runs once and is skipped for future
// steps, so userID will not be defined.
let userId;

// Do NOT do this!  Instead, return data from step.run.
await step.run("get-user", async () => {
  userId = await getRandomUserId();
});

console.log(userId); // undefined

Instead, make sure that any variables needed for the overall function are returned from calls to step.run():

// This is the right way to set variables within step.run :)
const userId = await step.run("get-user", () => getRandomUserId());

console.log(userId); // 123

sleepUntil() isn't working as expected

Make sure to only to use sleepUntil() with dates that will be static across the various calls to your function.

Always use sleep() if you'd like to wait a particular time from now.

// ❌ Bad
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
await step.sleepUntil("wait-until-tomorrow", tomorrow);

// ✅ Good
await step.sleep("wait-a-day", "1 day");
// ✅ Good
const userBirthday = await step.run("get-user-birthday", async () => {
  const user = await getUser();
  return user.birthday; // Date
});

await sleepUntil("wait-for-user-birthday", userBirthday);