---
title: "When nobody pings"
description: "Use Promise.race with sleep to add a timeout to the kitchen hook, escalating with an email when the kitchen ghosts the workflow."
canonical_url: "https://vercel.com/academy/workflow-foundations/what-if-the-kitchen-never-responds"
md_url: "https://vercel.com/academy/workflow-foundations/what-if-the-kitchen-never-responds.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-10-20T22:57:37.792Z"
content_type: "lesson"
course: "workflow-foundations"
course_title: "Build Durable Workflows"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# When nobody pings

# 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`:

```ts title="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:

```ts title="workflows/process-order.ts" {1,8-9,18-36}
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.

\*\*Note: 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:

```ts title="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.


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
