Vercel Logo

Pause until the kitchen pings us

Pizzas don't cook on a timer. They're ready when the kitchen says they're ready.

The sleep("30s") we added in 2.2 is a lie we agreed to until we had something better. A real pizza shop has no idea when a pizza will be ready. It might be three minutes. It might be twenty. The workflow needs to wait until someone in the kitchen actually says, "this one's done."

That's what hooks are for. A hook is a suspension point with a unique token. The workflow awaits the hook and goes to sleep. Some external request, like a button in the kitchen ops UI, calls resumeHook(token, payload) and the workflow wakes up with that payload as the value of the await.

Three pieces: the hook in the workflow, the token that ties them together, and the Route Handler that fires the wakeup.

Outcome

Replace the post-kitchen sleep("30s") with a hook the workflow waits on. Build /api/kitchen/ready so the existing kitchen ops UI can wake up the workflow when a pizza is ready.

Fast Track

  1. In processOrder, replace await sleep("30s") after acknowledgeKitchen with a createHook waiting on kitchen:{order.id}.
  2. Create app/api/kitchen/ready/route.ts that calls resumeHook(\kitchen:$`, )`.
  3. Place an order. Open /kitchen. Click Mark ready. Watch the workflow continue.

Hands-on exercise

1. Add the hook to the workflow.

workflows/process-order.ts:

workflows/process-order.ts
import { createHook } 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 { confirmDelivery } from "./steps/confirm-delivery";
import { sendReviewRequest } from "./steps/send-review-request";
 
export async function processOrder(order: Order): Promise<{ orderId: string }> {
  "use workflow";
 
  await sendOrderConfirmation(order);
  await acknowledgeKitchen(order);
 
  using kitchenHook = createHook<{ readyAt: string }>({
    token: `kitchen:${order.id}`,
  });
  await kitchenHook;
 
  await dispatchDelivery(order);
  await sleep("30s");
  await confirmDelivery(order);
  await sendReviewRequest(order);
 
  return { orderId: order.id };
}

Wait. There's something new here.

using is a TC39 syntax for automatic resource cleanup. When the workflow scope ends, the hook gets disposed automatically, so you don't have stale tokens floating around. You'll see it again every time we create a hook.

createHook<T>({ token }) returns a hook object. The generic T is the payload type. We're saying the kitchen will send us a { readyAt: string } when it wakes us up.

{ token: \kitchen:$` }is a custom token. Default tokens are random, but we want a deterministic one so the kitchen ops UI can construct it from data it already has (the order ID). Namespacing it withkitchen:` keeps tokens from colliding with the driver hooks we add in 3.2.

await kitchenHook suspends the workflow. Compute stops. The workflow waits, possibly for hours, until someone resumes the hook with that token.

2. Build the resume endpoint.

Create app/api/kitchen/ready/route.ts:

app/api/kitchen/ready/route.ts
import { resumeHook } from "workflow/api";
import { NextResponse } from "next/server";
 
export async function POST(request: Request) {
  const { orderId } = (await request.json()) as { orderId: string };
 
  await resumeHook(`kitchen:${orderId}`, {
    readyAt: new Date().toISOString(),
  });
 
  return NextResponse.json({ ok: true });
}

resumeHook takes the same token the workflow used and the payload to deliver. The runtime looks up the suspended workflow waiting on that token and wakes it up with the payload. The await on the workflow side resolves with what we passed here.

The kitchen ops UI in the starter already POSTs to this exact endpoint. We're making the endpoint exist.

Try It

Place an order. The status page should tick to "In the kitchen" and stay there.

In a new tab, open /kitchen. Your order is sitting in the oven with a Mark ready button.

Click it.

Two things happen in quick succession. The kitchen ops UI removes the order from the list. The customer's order page advances to "Out for delivery."

In the dashboard, look at the run timeline:

processOrder                running
  ├─ sendOrderConfirmation    completed   389ms
  ├─ acknowledgeKitchen       completed   208ms
  ├─ kitchen hook (kitchen:f8c2…)
  │      suspended  3m 22s
  │      resumed with { readyAt: "2026-..." }
  ├─ dispatchDelivery         completed   213ms
  └─ (sleep 30s)

The hook line shows how long the workflow was suspended (minutes, hours, doesn't matter) and the payload it woke up with.

Why use a custom token

We could have let createHook generate a random token, but then the kitchen ops UI would need to know that token to call resumeHook. With a deterministic token like kitchen:{order.id}, the UI just needs the order ID, which it already has from the order list. No extra round-trip to fetch a token.

Commit

feat(workflow): wait on a kitchen hook instead of fake sleep

Done-When

  • processOrder creates a hook with token kitchen:{order.id} after acknowledgeKitchen
  • The workflow awaits the hook before dispatchDelivery
  • app/api/kitchen/ready/route.ts calls resumeHook with the same token
  • Clicking Mark ready in /kitchen wakes the workflow and the order moves to "Out for delivery"

Solution

workflows/process-order.ts:

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 { confirmDelivery } from "./steps/confirm-delivery";
import { sendReviewRequest } from "./steps/send-review-request";
 
export async function processOrder(order: Order): Promise<{ orderId: string }> {
  "use workflow";
 
  await sendOrderConfirmation(order);
  await acknowledgeKitchen(order);
 
  using kitchenHook = createHook<{ readyAt: string }>({
    token: `kitchen:${order.id}`,
  });
  await kitchenHook;
 
  await dispatchDelivery(order);
  await sleep("30s");
  await confirmDelivery(order);
  await sendReviewRequest(order);
 
  return { orderId: order.id };
}

app/api/kitchen/ready/route.ts:

app/api/kitchen/ready/route.ts
import { resumeHook } from "workflow/api";
import { NextResponse } from "next/server";
 
export async function POST(request: Request) {
  const { orderId } = (await request.json()) as { orderId: string };
 
  await resumeHook(`kitchen:${orderId}`, {
    readyAt: new Date().toISOString(),
  });
 
  return NextResponse.json({ ok: true });
}

One hook, one Route Handler. The workflow can now wait for arbitrarily long without a fake timer. We do the same trick for the driver next.

Was this helpful?

supported.