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 astep.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 functionstep.sleep()
- Sleep for a given amount of timestep.sleepUntil()
- Sleep until a given timestep.invoke()
- Invoke another Inngest function as a step, receiving the result of the invoked functionstep.waitForEvent()
- Pause a function's execution until another event is receivedstep.sendEvent()
- Send event(s) reliability within your function. Use this instead ofinngest.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);