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
- Add a
sendEscalationEmailstep that sends an "we're checking on your order" email and sets status toescalated. - Update
processOrdertoPromise.racethe kitchen hook againstsleep("20m")with tagged results. - If the race resolves to
"timeout", callsendEscalationEmailand return early. Otherwise, continue as before.
Hands-on exercise
1. Write the escalation step.
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:
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.
Commit
feat(workflow): escalate when the kitchen doesn't respond
Done-When
workflows/steps/send-escalation-email.tsexists with the"use step"directiveprocessOrderraces the kitchen hook againstsleep(KITCHEN_TIMEOUT)using tagged Promises- The timeout branch calls
sendEscalationEmailand 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:
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?