Vercel Logo

What if the kitchen never responds?

The kitchen forgets. The printer jams. Sal is taking a nap and the order ticket lands on the floor behind the oven. Whatever the reason, a real ordering system has to assume the kitchen sometimes doesn't ping back.

Right now, the workflow waits forever. That's not "durable." That's just stuck.

The fix is two primitives we already have, awkwardly close to each other: a hook and a sleep. We race them with Promise.race. Whichever resolves first wins. If the hook wins, the kitchen pinged us; we keep going. If the sleep wins, the kitchen didn't ping us; we escalate.

This is the only "pattern" lesson in the course. Everything else is "use the primitive." This one combines primitives. Worth knowing.

Outcome

Add a 20-minute timeout to the kitchen hook. If the kitchen doesn't ping in time, send Marge an escalation email and stop the workflow.

Fast Track

  1. Add a sendEscalationEmail step that sends an "we're checking on your order" email and sets status to escalated.
  2. Update processOrder to Promise.race the kitchen hook against sleep("20m") with tagged results.
  3. If the race resolves to "timeout", call sendEscalationEmail and return early. Otherwise, continue as before.

Hands-on exercise

1. Write the escalation step.

workflows/steps/send-escalation-email.ts:

workflows/steps/send-escalation-email.ts
import { FatalError } from "workflow";
import { resend, FROM } from "@/lib/resend";
import { updateStatus } from "@/lib/orders-store";
import type { Order } from "@/lib/pizza";
 
export async function sendEscalationEmail(order: Order): Promise<void> {
  "use step";
 
  const resp = await resend.emails.send({
    from: FROM,
    to: [order.email],
    subject: "We're checking on your order",
    html: `
      <p>Hi ${order.customerName},</p>
      <p>Your ${order.pizza} is taking longer than usual. We're calling the kitchen now.</p>
      <p>Sal</p>
    `,
  });
 
  if (resp.error) {
    throw new FatalError(`Resend failed: ${resp.error.message}`);
  }
 
  updateStatus(order.id, "escalated");
}

2. Race the hook against sleep.

Update workflows/process-order.ts. Only the section between the kitchen ack and the dispatch changes:

workflows/process-order.ts
import { createHook, sleep } from "workflow";
import type { Order } from "@/lib/pizza";
import { sendOrderConfirmation } from "./steps/send-order-confirmation";
import { acknowledgeKitchen } from "./steps/acknowledge-kitchen";
import { dispatchDelivery } from "./steps/dispatch-delivery";
import { markOutForDelivery } from "./steps/mark-out-for-delivery";
import { confirmDelivery } from "./steps/confirm-delivery";
import { sendReviewRequest } from "./steps/send-review-request";
import { sendEscalationEmail } from "./steps/send-escalation-email";
 
const KITCHEN_TIMEOUT = "20m";
 
export async function processOrder(order: Order): Promise<{
  status: "delivered" | "escalated";
  orderId: string;
}> {
  "use workflow";
 
  await sendOrderConfirmation(order);
  await acknowledgeKitchen(order);
 
  using kitchenHook = createHook<{ readyAt: string }>({
    token: `kitchen:${order.id}`,
  });
 
  const kitchenResult = await Promise.race([
    kitchenHook.then((payload) => ({ kind: "ready" as const, payload })),
    sleep(KITCHEN_TIMEOUT).then(() => ({ kind: "timeout" as const })),
  ]);
 
  if (kitchenResult.kind === "timeout") {
    await sendEscalationEmail(order);
    return { status: "escalated", orderId: order.id };
  }
 
  await dispatchDelivery(order);
 
  using pickupHook = createHook<{ pickedUpAt: string }>({
    token: `driver-pickup:${order.id}`,
  });
  await pickupHook;
  await markOutForDelivery(order);
 
  using deliveryHook = createHook<{ deliveredAt: string }>({
    token: `driver-delivered:${order.id}`,
  });
  await deliveryHook;
 
  await confirmDelivery(order);
  await sendReviewRequest(order);
 
  return { status: "delivered", orderId: order.id };
}

Two things to call out.

The .then(...) calls tag each promise's resolution with a kind discriminator. That's what makes the result type narrow correctly in the if block. Without the tags, the result is just unknown | undefined and you have to squint to tell which side won.

Promise.race is the documented way to add a timeout to a hook in the Workflow SDK. The runtime treats sleep and the hook as ordinary promises here. Whichever resolves first, that's the value of the await. The whole pattern is composable in a way that built-in timeouts wouldn't be.

The workflow's return type now includes both outcomes: "delivered" and "escalated". The caller of start(processOrder, [order]) can await run.returnValue later and react appropriately.

Try It

Drop the timeout to something short while you're testing. Change KITCHEN_TIMEOUT to "30s" so you don't have to wait 20 minutes. Deploy that change, then place an order.

Don't touch the kitchen ops UI. Just wait.

30 seconds in, the run advances past the hook with kind: "timeout". The escalation email arrives. The status moves to "Order escalated." The workflow ends with { status: "escalated", orderId }.

In the dashboard:

processOrder                completed   42.1s
  ├─ sendOrderConfirmation    completed   389ms
  ├─ acknowledgeKitchen       completed   208ms
  ├─ kitchen hook             timed out (30s)
  └─ sendEscalationEmail      completed   341ms

Now place a second order. This time, click Mark ready in the kitchen UI within 30 seconds. The race resolves to "ready". The escalation email is never sent. The workflow continues to dispatch, pickup, delivery, review.

Change KITCHEN_TIMEOUT back to "20m" before shipping anything you care about.

The Promise.race trick generalizes

This is the same pattern you'd use to time out any waiting workflow step. Race the thing you're waiting on against sleep(...). Tag both sides so the result type narrows. Branch on the tag. It works for hooks, webhooks, even a slow third-party API wrapped in a step.

Commit

feat(workflow): escalate when the kitchen doesn't respond

Done-When

  • workflows/steps/send-escalation-email.ts exists with the "use step" directive
  • processOrder races the kitchen hook against sleep(KITCHEN_TIMEOUT) using tagged Promises
  • The timeout branch calls sendEscalationEmail and returns early
  • The workflow return type is { status: "delivered" | "escalated", orderId: string }
  • You've tested both branches: one timeout, one normal completion

Solution

The escalation step appears in the hands-on section above. The complete workflow:

workflows/process-order.ts
import { createHook, sleep } from "workflow";
import type { Order } from "@/lib/pizza";
import { sendOrderConfirmation } from "./steps/send-order-confirmation";
import { acknowledgeKitchen } from "./steps/acknowledge-kitchen";
import { dispatchDelivery } from "./steps/dispatch-delivery";
import { markOutForDelivery } from "./steps/mark-out-for-delivery";
import { confirmDelivery } from "./steps/confirm-delivery";
import { sendReviewRequest } from "./steps/send-review-request";
import { sendEscalationEmail } from "./steps/send-escalation-email";
 
const KITCHEN_TIMEOUT = "20m";
 
export async function processOrder(order: Order): Promise<{
  status: "delivered" | "escalated";
  orderId: string;
}> {
  "use workflow";
 
  await sendOrderConfirmation(order);
  await acknowledgeKitchen(order);
 
  using kitchenHook = createHook<{ readyAt: string }>({
    token: `kitchen:${order.id}`,
  });
 
  const kitchenResult = await Promise.race([
    kitchenHook.then((payload) => ({ kind: "ready" as const, payload })),
    sleep(KITCHEN_TIMEOUT).then(() => ({ kind: "timeout" as const })),
  ]);
 
  if (kitchenResult.kind === "timeout") {
    await sendEscalationEmail(order);
    return { status: "escalated", orderId: order.id };
  }
 
  await dispatchDelivery(order);
 
  using pickupHook = createHook<{ pickedUpAt: string }>({
    token: `driver-pickup:${order.id}`,
  });
  await pickupHook;
  await markOutForDelivery(order);
 
  using deliveryHook = createHook<{ deliveredAt: string }>({
    token: `driver-delivered:${order.id}`,
  });
  await deliveryHook;
 
  await confirmDelivery(order);
  await sendReviewRequest(order);
 
  return { status: "delivered", orderId: order.id };
}

The workflow is now feature-complete on the happy path. Section 4 is where it stops pretending the unhappy path doesn't exist.

Was this helpful?

supported.