Skip to main content

Step Coordination: Waiting for External Events

Here's a capability that sounds simple but unlocks an entirely new class of workflows: pausing a function mid-execution and waiting for something to happen in the outside world before continuing.

Not polling. Not a cron that checks a database. Not a webhook that re-triggers the whole flow. The function literally pauses — consuming zero compute — and resumes the moment a matching event arrives, or after a timeout expires, whichever comes first.

This is step.waitForEvent(). It's the primitive that lets you build:

  • Onboarding drip campaigns that adapt to user behaviour in real time
  • Approval workflows that pause until a manager clicks "Approve"
  • Cart abandonment flows that stop when the user actually checks out
  • Email verification sequences that continue when the link is clicked
  • Document signing flows that advance when each party signs
  • Human-in-the-loop AI workflows that wait for a reviewer before proceeding

Let's discover how it works.


Quick Reference

What it does: Pauses a function run until a specific event arrives, or until a timeout expires — whichever comes first.

Returns: The full event payload if the event arrived, or null if the timeout expired first.

Cost while waiting: Zero compute. The function isn't running — Inngest holds its state and resumes it when the event arrives.

Maximum wait duration: Up to 1 year.

Event matching: Use match (shorthand for same-field equality) or if (full CEL expression) to ensure you resume only on the right event for this specific run.

Critical gotcha: step.waitForEvent() only catches events sent after the step begins. Events sent before the step starts will not be received.


What You Need to Know First

Required reading (in order):

  1. Event-Driven Architecture: Why Your App Needs It
  2. Events, Queues, and Workers: The Building Blocks
  3. Inngest: What It Is and How It Fits In
  4. Your First Inngest Function
  5. Steps: Breaking Work into Durable Units

You should understand:

  • How step.run() checkpoints work and what memoisation means
  • How inngest.send() fires events from application code
  • The re-execution model (your handler runs once per step)

What We'll Cover in This Article

By the end of this guide, you'll understand:

  • How step.waitForEvent() works — the full pause/resume lifecycle
  • The match shorthand vs the if expression — when to use each
  • How timeout and null-return interact with conditional logic
  • Multiple real-world patterns: onboarding, approvals, cart abandonment, document signing
  • The critical race condition gotcha and how to reason about it
  • How cancelOn combines with waitForEvent to cancel superseded runs
  • step.waitForEvent() vs step.sleep() — choosing the right primitive

What We'll Explain Along the Way

  • CEL (Common Expression Language) — what it is and how to read if expressions
  • Why pausing doesn't consume compute (and why this is important for serverless)
  • What "resuming" actually means under the hood

Part 1: The Core Concept — Pause, Wait, Resume

Before diving into code, let's build the mental model clearly.

A typical step.run() call executes and returns within seconds. step.waitForEvent() is fundamentally different: it sets up a listener for a future event, then pauses the function entirely. The function run sits in a "Waiting" state in Inngest — not running, not consuming CPU or memory. Just waiting.

When the matching event arrives, Inngest resumes the function from exactly where it paused. The step resolves with the event payload, and the function continues executing normally.

If no matching event arrives before the timeout expires, the step resolves with null, and the function continues — giving you a branch to handle the "they didn't do the thing" path.

Timeline:

t=0s Function starts
t=0.5s step.run("send-welcome-email") → completes
t=0.5s step.waitForEvent("wait-for-activation") begins

FUNCTION PAUSED — no compute consumed

t=??? One of two things happens:

A) User activates their account: "user/activated" event fires
→ Function resumes immediately
→ waitForEvent returns the activation event payload
→ Function continues with "they activated" path

B) 3 days pass with no activation event
→ Inngest resumes the function after timeout
→ waitForEvent returns null
→ Function continues with "they didn't activate" path

This is qualitatively different from any polling or cron-based approach. The function doesn't wake up every hour to check a database. It waits once, and resumes exactly when needed.

As Inngest's documentation explains: "Events fan out to many functions: you can resume many runs from a single event, without changing application code." A single user/activated event can simultaneously resume thousands of paused function runs — one per user who activated at that moment — all matching on their respective user IDs.


Part 2: The API in Detail

Basic syntax

const result = await step.waitForEvent(
"step-id", // Unique ID for this step (like any step)
{
event: "event/name", // The event name to listen for (required)
timeout: "24h", // How long to wait before giving up (required)
match: "data.userId", // Shorthand: match this field in both events (optional)
// OR:
if: "event.data.userId == async.data.userId", // Full expression (optional)
},
);

// result is either:
// - The full event payload object (if the event arrived in time)
// - null (if the timeout expired before the event arrived)

The timeout option

All durations use the ms package format: "30s", "10m", "2h", "3d", "2 weeks", "1 year".

// Some practical timeout examples:
timeout: "10m"; // Email click confirmation — short window
timeout: "24h"; // Cart abandonment — one day
timeout: "3d"; // Onboarding nudge — a few days
timeout: "7d"; // Approval workflow — a week
timeout: "30d"; // Subscription upgrade — a month
timeout: "1y"; // Contract signing — up to a year

The match shorthand

match takes a dot-notation field path and requires that field to have the same value in both the triggering event (event) and the waited-for event. This is the most common case — you want the function run for user A to only resume when user A's event arrives, not user B's.

// match: "data.userId"
// is equivalent to:
// if: "event.data.userId == async.data.userId"
//
// It means: the userId field in the triggering event's data
// must equal the userId field in the incoming event's data.

const emailVerified = await step.waitForEvent("wait-for-email-verification", {
event: "user/email.verified",
timeout: "48h",
match: "data.userId", // event.data.userId == async.data.userId
});

The if expression (CEL)

if takes a full expression in Common Expression Language (CEL). This is a simple, readable expression format — think of it like a JavaScript boolean expression, but evaluated by Inngest's engine.

In an if expression:

  • event refers to the original triggering event (the one that started this function)
  • async refers to the incoming event being evaluated (the potential match)
// Wait for a subscription upgrade, but only to the 'pro' plan
const subscription = await step.waitForEvent("wait-for-pro-subscription", {
event: "subscription/created",
timeout: "30d",
if: "event.data.userId == async.data.userId && async.data.plan == 'pro'",
// ↑ matches same user ↑ and only if the plan is 'pro'
});

// Wait for an invoice approval for a specific invoice
const approval = await step.waitForEvent("wait-for-invoice-approval", {
event: "invoice/approved",
timeout: "7d",
if: "async.data.invoiceId == event.data.invoiceId && async.data.approverId != event.data.requesterId",
// ↑ matches same invoice ↑ and approved by someone other than the requester
});

You cannot combine match and if in the same call — use one or the other. Use match for simple same-field equality; use if when you need more complex logic.

Handling the return value

Always check whether the result is null or an event payload:

const activationEvent = await step.waitForEvent("wait-for-activation", {
event: "user/account.activated",
timeout: "3d",
match: "data.userId",
});

if (activationEvent === null) {
// Timed out — user did not activate within 3 days
await step.run("send-activation-nudge", async () => {
await emailService.sendActivationNudge(event.data.email);
});
} else {
// User activated — activationEvent contains the full event payload
await step.run("send-tips-email", async () => {
await emailService.sendWelcomeTips({
email: event.data.email,
activatedAt: activationEvent.data.activatedAt, // data from the received event
firstFeatureUsed: activationEvent.data.firstFeature,
});
});
}

Part 3: Real-World Patterns

Pattern 1: Onboarding drip campaign

The classic use case — a multi-step email sequence that adapts based on what the user actually does.

export const onboardingCampaign = inngest.createFunction(
{ id: "onboarding-drip-campaign" },
{ event: "user/account.created" },
async ({ event, step }) => {
// Immediately send the welcome email
await step.run("send-welcome-email", async () => {
await emailService.send({
to: event.data.email,
template: "welcome",
data: { name: event.data.name },
});
});

// Wait up to 24 hours for the user to complete their profile
const profileCompleted = await step.waitForEvent(
"wait-for-profile-completion",
{
event: "user/profile.completed",
timeout: "24h",
match: "data.userId",
},
);

if (!profileCompleted) {
// They haven't completed their profile — send a nudge
await step.run("send-profile-nudge", async () => {
await emailService.send({
to: event.data.email,
template: "complete-your-profile",
});
});
}

// Pause until day 3
await step.sleep("pause-until-day-3", "3d");

// Wait up to 3 days for their first meaningful action
// (counting from when we reach this step)
const firstAction = await step.waitForEvent("wait-for-first-action", {
event: "user/first-action.completed",
timeout: "3d",
match: "data.userId",
});

if (!firstAction) {
// Day 6 with no action — send an activation email
await step.run("send-activation-email", async () => {
await emailService.send({
to: event.data.email,
template: "activation-guide",
});
});
} else {
// They took action — congratulate and guide them to the next step
await step.run("send-progress-email", async () => {
await emailService.send({
to: event.data.email,
template: "great-progress",
data: { actionType: firstAction.data.actionType },
});
});
}
},
);

This function can run for up to 9 days (3-day sleep + 3-day wait for action), consuming compute only for the milliseconds it takes to actually send emails. Everything in between is free waiting.

Pattern 2: Approval workflow

A document or request is submitted, a manager must approve it within a deadline, and the outcome determines what happens next.

export const documentApprovalWorkflow = inngest.createFunction(
{ id: "document-approval-workflow" },
{ event: "document/submitted-for-approval" },
async ({ event, step }) => {
// Notify the approvers
await step.run("notify-approvers", async () => {
await emailService.send({
to: event.data.approverEmails,
template: "approval-required",
data: {
documentId: event.data.documentId,
title: event.data.title,
submittedBy: event.data.requesterName,
approvalUrl: `https://app.example.com/approve/${event.data.documentId}`,
},
});
});

// Wait up to 7 days for an approval decision
const decision = await step.waitForEvent("wait-for-approval-decision", {
event: "document/approval-decision",
timeout: "7d",
// Match on documentId AND ensure the decision is about THIS document
if: `async.data.documentId == "${event.data.documentId}"`,
});

if (decision === null) {
// Nobody responded within 7 days — escalate
await step.run("escalate-to-manager", async () => {
await emailService.send({
to: event.data.managerEmail,
template: "approval-overdue",
data: {
documentId: event.data.documentId,
daysOverdue: 0,
requesterName: event.data.requesterName,
},
});
});

await db.documents.update(event.data.documentId, {
status: "approval_overdue",
});

return { status: "escalated" };
}

if (decision.data.approved) {
// Approved — notify the requester and process the document
await step.run("handle-approval", async () => {
await db.documents.update(event.data.documentId, {
status: "approved",
});
await emailService.send({
to: event.data.requesterEmail,
template: "document-approved",
data: {
title: event.data.title,
approvedBy: decision.data.approverName,
comments: decision.data.comments,
},
});
});

return { status: "approved", approvedBy: decision.data.approverName };
} else {
// Rejected — notify the requester with feedback
await step.run("handle-rejection", async () => {
await db.documents.update(event.data.documentId, {
status: "rejected",
});
await emailService.send({
to: event.data.requesterEmail,
template: "document-rejected",
data: {
title: event.data.title,
rejectedBy: decision.data.approverName,
reason: decision.data.rejectionReason,
},
});
});

return { status: "rejected", reason: decision.data.rejectionReason };
}
},
);

// When a manager clicks "Approve" or "Reject" in your app:
async function handleApprovalAction(req: Request) {
const {
documentId,
approved,
approverId,
approverName,
comments,
rejectionReason,
} = await req.json();

// Firing this event resumes the paused workflow immediately
await inngest.send({
name: "document/approval-decision",
data: {
documentId,
approved,
approverId,
approverName,
comments,
rejectionReason,
},
});

return Response.json({ status: "decision recorded" });
}

Pattern 3: Cart abandonment

A user adds a product to their cart. If they don't check out within 24 hours, send a reminder. The beauty here: if they do check out, the function gracefully exits without sending a nagging email.

export const cartAbandonmentFlow = inngest.createFunction(
{
id: "cart-abandonment-flow",
// Cancel older instances of this function when the same cart gets a new product
// (the new run will start a fresh 24h countdown)
cancelOn: [
{
event: "cart/product.added",
match: "data.cartId",
},
],
},
{ event: "cart/product.added" },
async ({ event, step }) => {
// Wait up to 24 hours for a checkout event from the same cart
const purchased = await step.waitForEvent("wait-for-checkout", {
event: "cart/checkout.completed",
timeout: "24h",
match: "data.cartId", // Only resume for THIS cart
});

if (purchased !== null) {
// They checked out — nothing to do, the purchase flow handles it
return { status: "purchased" };
}

// 24 hours passed with no checkout — send the reminder
await step.run("send-abandonment-reminder", async () => {
const cart = await db.carts.findById(event.data.cartId);
await emailService.sendCartReminder({
to: event.data.userEmail,
cartItems: cart.items,
cartId: event.data.cartId,
recoveryUrl: `https://shop.example.com/cart/${event.data.cartId}/recover`,
});
});

return { status: "reminder-sent" };
},
);

Notice the cancelOn option. Without it, every time a user adds a product to their cart, a new abandonment run starts — so adding 3 products would create 3 separate abandonment functions all waiting 24 hours. With cancelOn, when a new cart/product.added event arrives for the same cart ID, any older runs for that cart are automatically cancelled. Only the most recent run survives.

Pattern 4: Email verification sequence

A user registers, you send a verification email, and you need to wait for them to click the link before proceeding.

export const emailVerificationFlow = inngest.createFunction(
{ id: "email-verification-flow" },
{ event: "user/registered" },
async ({ event, step }) => {
// Generate a verification token
const token = await step.run("generate-verification-token", async () => {
const tok = crypto.randomUUID();
await db.verificationTokens.create({
token: tok,
userId: event.data.userId,
expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000),
});
return tok;
});

// Send the verification email
await step.run("send-verification-email", async () => {
await emailService.sendVerification({
to: event.data.email,
verificationUrl: `https://app.example.com/verify?token=${token}`,
});
});

// Wait up to 48 hours for the user to click the link
// Your /verify endpoint fires this event when the link is clicked
const verified = await step.waitForEvent("wait-for-verification", {
event: "user/email.verified",
timeout: "48h",
match: "data.userId",
});

if (!verified) {
// Verification link expired — send a resend option
await step.run("send-resend-prompt", async () => {
await emailService.sendVerificationExpired({
to: event.data.email,
resendUrl: `https://app.example.com/resend-verification`,
});
});

await db.users.update(event.data.userId, {
status: "verification_expired",
});
return { status: "expired" };
}

// Verified! Activate the account
await step.run("activate-account", async () => {
await db.users.update(event.data.userId, {
status: "active",
emailVerifiedAt: new Date().toISOString(),
});
});

// Send the get-started guide
await step.run("send-welcome-guide", async () => {
await emailService.sendWelcomeGuide({ to: event.data.email });
});

return { status: "verified-and-active" };
},
);

// Your verification endpoint — fires the event when the link is clicked
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const token = searchParams.get("token");

const record = await db.verificationTokens.findByToken(token);
if (!record || record.expiresAt < new Date()) {
return Response.redirect("/verification-expired");
}

// This fires the event that resumes the waiting function
await inngest.send({
name: "user/email.verified",
data: { userId: record.userId, verifiedAt: new Date().toISOString() },
});

return Response.redirect("/dashboard?verified=true");
}

Part 4: The Race Condition Gotcha

This is the most important limitation of step.waitForEvent(), and it's documented clearly by Inngest's official docs: step.waitForEvent() only catches events sent after the step begins executing. Events sent before the step starts will not be caught.

Let's make this concrete. Suppose you have a very fast user — they verify their email before the verification email step has even finished:

Timeline:

t=0ms User registers → "user/registered" fires
t=5ms Function starts, begins "generate-verification-token" step
t=10ms Token generated, "send-verification-email" begins
t=15ms Email sent... but this is a speedy test environment
User (or your test) fires "user/email.verified" RIGHT NOW
t=15ms "user/email.verified" fires ← arrives BEFORE waitForEvent registers
t=20ms step.waitForEvent("wait-for-verification") begins
→ Listens for future "user/email.verified" events
→ The event that fired at t=15ms is NOT seen — it already passed
→ Function waits 48 hours, then resolves null

In practice this rarely happens with real users — there's always at least a few seconds between sending an email and a human clicking a link. But it becomes a real concern in:

  • Automated tests where you fire both events in rapid succession
  • Webhooks from fast external systems that respond almost instantly
  • Machine-to-machine workflows where both sides are automated

The Inngest GitHub issue tracker has a documented example of this: sending eventB (which waits for eventA) and then immediately sending eventA without a small delay causes eventB to never receive eventA.

How to reason about this:

When designing a workflow, ensure that the event you're waiting for cannot logically arrive before the waitForEvent step starts. In the email verification case, this is naturally guaranteed — a human can't click a link before the email exists. In automated scenarios, introduce a deliberate ordering guarantee or small delay in your test setup.

// In test environments: ensure the triggering event starts first
await inngest.send({ name: "user/registered", data: { userId: "test_001" } });

// Give the function time to start and reach the waitForEvent step
await new Promise((resolve) => setTimeout(resolve, 2000));

// Now send the response event — the function is already waiting
await inngest.send({
name: "user/email.verified",
data: { userId: "test_001" },
});

Part 5: step.waitForEvent() vs step.sleep() — Choosing the Right Primitive

Both primitives pause a function. The difference is what resumes it:

step.sleep()step.waitForEvent()
Resumes whenA fixed duration elapsesA matching event arrives OR timeout expires
ReturnsNothing (void)The event payload OR null
Use whenYou want a fixed delay regardless of what happensYou want to react to something that may or may not happen
Example"Send a follow-up email 7 days after signup""Send a follow-up email if the user hasn't upgraded in 7 days"
// step.sleep: unconditional time-based delay
await step.sleep("wait-7-days", "7d");
// Function resumes after exactly 7 days, regardless of anything
await step.run("send-follow-up", ...);

// step.waitForEvent: conditional, time-bounded wait
const upgraded = await step.waitForEvent("wait-for-upgrade", {
event: "subscription/upgraded",
timeout: "7d",
match: "data.userId",
});

if (!upgraded) {
// Only send if they haven't upgraded yet
await step.run("send-upgrade-nudge", ...);
}
// If they upgraded during the 7 days, skip the nudge

You can also combine them — sleep for a fixed period, then conditionally wait for an event:

// Day 1: Send welcome email
await step.run("send-welcome", ...);

// Day 3: Check in
await step.sleep("wait-3-days", "3d");
await step.run("send-day-3-checkin", ...);

// Wait for action but resume after 4 more days if nothing
const actionTaken = await step.waitForEvent("wait-for-action", {
event: "user/first-action.completed",
timeout: "4d", // Wait until day 7 total
match: "data.userId",
});

if (!actionTaken) {
await step.run("send-activation-nudge", ...);
}

Part 6: Multiple Sequential Waits

You can use step.waitForEvent() multiple times in sequence — building multi-stage workflows where each stage waits for a different signal.

export const contractSigningWorkflow = inngest.createFunction(
{ id: "contract-signing-workflow" },
{ event: "contract/sent-for-signing" },
async ({ event, step }) => {
// Wait for party A to sign (up to 14 days)
const partyASignature = await step.waitForEvent("wait-for-party-a", {
event: "contract/signature.received",
timeout: "14d",
if: `async.data.contractId == "${event.data.contractId}" && async.data.signerRole == "party_a"`,
});

if (!partyASignature) {
await step.run("handle-party-a-timeout", async () => {
await contractService.markExpired(
event.data.contractId,
"party_a_timeout",
);
await emailService.sendContractExpired(event.data.partyAEmail);
});
return { status: "expired", reason: "party_a_did_not_sign" };
}

// Party A signed — notify Party B it's their turn
await step.run("notify-party-b", async () => {
await emailService.sendSignatureRequest({
to: event.data.partyBEmail,
contractId: event.data.contractId,
signedByA: partyASignature.data.signedAt,
signingUrl: `https://app.example.com/sign/${event.data.contractId}/party-b`,
});
});

// Wait for party B to sign (up to 14 more days)
const partyBSignature = await step.waitForEvent("wait-for-party-b", {
event: "contract/signature.received",
timeout: "14d",
if: `async.data.contractId == "${event.data.contractId}" && async.data.signerRole == "party_b"`,
});

if (!partyBSignature) {
await step.run("handle-party-b-timeout", async () => {
await contractService.markExpired(
event.data.contractId,
"party_b_timeout",
);
await emailService.sendContractExpired(event.data.partyBEmail);
});
return { status: "expired", reason: "party_b_did_not_sign" };
}

// Both parties signed — finalize the contract
await step.run("finalize-contract", async () => {
await contractService.markFullySigned(event.data.contractId, {
partyASignedAt: partyASignature.data.signedAt,
partyBSignedAt: partyBSignature.data.signedAt,
});
await emailService.sendContractFullySigned({
partyAEmail: event.data.partyAEmail,
partyBEmail: event.data.partyBEmail,
contractId: event.data.contractId,
});
});

return {
status: "fully-signed",
partyASignedAt: partyASignature.data.signedAt,
partyBSignedAt: partyBSignature.data.signedAt,
};
},
);

This workflow can run for up to 28 days (14 for party A + 14 for party B), consuming compute only for the brief moments when each party signs. The entire state of the workflow — who has signed, what was in the contract, what the original event payload was — is preserved by Inngest across the entire duration.


Common Misconceptions

❌ Misconception: step.waitForEvent() runs a background process that watches for events

Reality: There is no background process. The function pauses completely — its state is held by Inngest's platform. When a matching event arrives, Inngest's infrastructure resumes the function by calling your /api/inngest endpoint again, with the saved state and the new event. Your application code is not involved during the wait.

❌ Misconception: Waiting costs you compute

Reality: Compute is only consumed during the brief moments when your function handler is actually executing — generating the token, sending the email, processing the result. The days-long wait between these moments is free. This is why step.waitForEvent() is viable for waiting up to a year — the cost is negligible.

❌ Misconception: You can wait for an event that was already sent before the step started

Reality: This is the gotcha from Part 4. step.waitForEvent() only catches events sent after it starts listening. Events that arrived before it registered are missed. Design your workflow so the waited-for event cannot logically arrive before the step executes, or add ordering guarantees in automated scenarios.

❌ Misconception: match and if serve the same purpose

Reality: match: "data.userId" is a convenience shorthand that expands to if: "event.data.userId == async.data.userId". They both match the triggering event's field against the incoming event's same field. Use match for this simple equality case. Use if when you need to match on different fields, combine multiple conditions, or match against a hardcoded value.


Troubleshooting Common Issues

Problem: The function always resolves null (timeout) even though the event was sent

Most likely cause: The event was sent before step.waitForEvent() started listening. See Part 4.

Diagnostic steps:

// Check the timeline in the Dev Server:
// 1. Open the failing run
// 2. Look at the timestamp when "wait-for-X" step started
// 3. Compare with the timestamp of the event you sent
// If your event timestamp is BEFORE the step start timestamp → race condition

// For tests: add a delay between triggering the function and sending the waited event

Problem: Multiple function runs are receiving the same event

Cause: If you have many runs all waiting for the same event without a match or if filter, any instance of that event will resume all of them simultaneously. This is often a feature (fan-out via waitForEvent), but can be a bug if you expected only one run to resume.

Solution: Always add a match or if to scope the wait to a specific identifier (userId, orderId, etc.).

Problem: Old runs keep timing out after you fix a bug

Scenario: You had a bug where step.waitForEvent() wasn't matching correctly, so many runs timed out and reached the null branch. You fixed the bug, but you still see old runs occasionally timing out.

Why it happens: Those runs were created before your fix and are still waiting with the old matching logic. New runs use the new logic, old runs use the memoised state from when they were created.

Resolution: Let the old runs time out (or cancel them manually from the Dev Server dashboard). New runs will behave correctly.


Check Your Understanding

Quick Quiz

1. step.waitForEvent() returns null. What does that mean, and what should you do with it?

Show Answer

It means the timeout expired before a matching event arrived. The "thing you were waiting for" didn't happen in time. You should handle this in an if (!result) branch — typically by sending a nudge message, escalating to a human, marking a record as expired, or taking whatever action makes sense for the "didn't happen" path.

This is not an error — it's the expected "timeout path" that you must explicitly handle in your code.

2. You have 10,000 users all in an onboarding drip function, each waiting on user/first-action.completed. User #5,231 completes their first action. How many functions resume?

Show Answer

Exactly one — the function run for user #5,231. This is why the match (or if) option is essential. Each function run registered with match: "data.userId" will only resume when an event arrives with a matching userId. All 9,999 other runs continue waiting for their own users' events.

If you forgot to include match or if, all 10,000 runs would attempt to resume on any user/first-action.completed event — which would be catastrophic.

3. What's the difference between match: "data.orderId" and if: "async.data.orderId == event.data.orderId"?

Show Answer

They are functionally identical. match: "data.orderId" is a shorthand that expands to exactly if: "async.data.orderId == event.data.orderId". Use match for this simple case — it's cleaner and less error-prone. Use if when you need to add additional conditions, compare different fields, or match against a literal value.

Hands-On Challenge

Design an invoice payment reminder workflow using step.waitForEvent().

Business logic:

  • When an invoice is sent, start the workflow
  • Wait up to 30 days for payment
  • If paid within 30 days — thank the customer and update records
  • If not paid by day 30 — send a payment reminder and wait another 14 days
  • If still not paid by day 44 — escalate to the finance team and mark overdue

Events to fire from your app:

  • invoice/sent (triggers the workflow)
  • invoice/payment.received (fires when payment comes in — should resume the wait)
See a Suggested Solution
export const invoicePaymentWorkflow = inngest.createFunction(
{ id: "invoice-payment-workflow" },
{ event: "invoice/sent" },
async ({ event, step }) => {
// First 30 days: wait for payment
const payment = await step.waitForEvent("wait-for-initial-payment", {
event: "invoice/payment.received",
timeout: "30d",
match: "data.invoiceId",
});

if (payment) {
// Paid in time — all good
await step.run("handle-payment", async () => {
await db.invoices.update(event.data.invoiceId, {
status: "paid",
paidAt: payment.data.paidAt,
amount: payment.data.amount,
});
await emailService.sendPaymentThankYou({
to: event.data.customerEmail,
invoiceId: event.data.invoiceId,
});
});
return { status: "paid" };
}

// 30 days elapsed — send reminder
await step.run("send-30-day-reminder", async () => {
await emailService.sendPaymentReminder({
to: event.data.customerEmail,
invoiceId: event.data.invoiceId,
daysOverdue: 0,
amount: event.data.amount,
});
});

// Wait another 14 days
const latePayment = await step.waitForEvent("wait-for-late-payment", {
event: "invoice/payment.received",
timeout: "14d",
match: "data.invoiceId",
});

if (latePayment) {
// Paid late — update records
await step.run("handle-late-payment", async () => {
await db.invoices.update(event.data.invoiceId, {
status: "paid_late",
paidAt: latePayment.data.paidAt,
});
});
return { status: "paid_late" };
}

// 44 days total — escalate
await step.run("escalate-to-finance", async () => {
await db.invoices.update(event.data.invoiceId, { status: "overdue" });
await emailService.sendFinanceEscalation({
to: event.data.financeTeamEmail,
invoiceId: event.data.invoiceId,
customerName: event.data.customerName,
amount: event.data.amount,
daysSinceSent: 44,
});
});

return { status: "escalated" };
},
);

Summary: Key Takeaways

  • step.waitForEvent(id, options) pauses a function until a matching event arrives or a timeout expires — consuming zero compute during the wait.
  • Returns the full event payload if the event arrived, or null if the timeout expired. Always handle both branches.
  • match is a shorthand for same-field equality between the triggering event and the incoming event. if allows full CEL expressions for complex matching. Never combine them.
  • The race condition gotcha: step.waitForEvent() only catches events sent after it starts. Events that arrived before it registered are missed. This is especially relevant for tests and fast automated scenarios.
  • cancelOn cancels older runs of a function when a new matching event arrives — essential for cart abandonment and similar "restart the clock" patterns.
  • step.sleep() is for unconditional time delays. step.waitForEvent() is for conditional time-bounded waits where you care about whether something happened, not just that time passed.
  • Functions can contain multiple step.waitForEvent() calls in sequence, enabling complex multi-stage human workflows spanning days or weeks.

What's Next?

You've now mastered Inngest's most powerful coordination primitive — the ability to pause mid-workflow and resume on external signals.

In Article 9: Scheduled Functions — Cron Jobs with Inngest (coming soon), we switch from reactive to proactive — writing functions that run on a schedule, fan out to process items in bulk, and handle the operational patterns that keep a production system healthy over time.


Version Information

Tested with:

  • inngest: ^4.1.x
  • Node.js: v18.x, v20.x, v22.x
  • TypeScript: 5.x

Limits:

  • Maximum step.waitForEvent() timeout: 1 year
  • CEL expression length: no documented limit, but keep expressions readable

Further reading: