Pre-started Cloudflare Workflow instances, so dispatch doesn't pay the cold start.
Results · API guide · Benchmark findings · Design
- Skip the cold start: a dispatch claims an instance that is already created, scheduled, and parked on
waitForEvent, then sends it the input. On a deployed Worker the first step runs about 6× sooner than aftercreate(). - Never wait for the pool: if no warmed instance is available, or the coordinator is unreachable, dispatch falls back to an ordinary cold
create(). - Typed end to end: input and output are Effect schemas.
pools.checkout.dispatch(input)is typed per pool, and the same registry becomes an EffectHttpApiwith a typed client.
import { WorkflowPool, WorkflowPoolRegistry } from "@cf-workflow-pool/wf-pool";
import { Schema } from "effect";
export class CheckoutPool extends WorkflowPool<CheckoutPool>()("CheckoutPool", {
input: Schema.Struct({ orderId: Schema.String, sku: Schema.String, quantity: Schema.Int }),
output: Schema.Struct({ orderId: Schema.String, reservationId: Schema.String }),
poolSize: 10,
replenishAtSize: 5,
warmedTimeout: "10 minutes",
}) {}
export class OrderWorkflowPools extends WorkflowPoolRegistry<OrderWorkflowPools>()(
"OrderWorkflowPools",
{ checkout: CheckoutPool },
) {}
// inside any Effect with OrderWorkflowPools in context
const pools = yield * OrderWorkflowPools;
const run =
yield *
pools.checkout.dispatch(
{ orderId: "order-8812", sku: "sku-1", quantity: 2 },
{ idempotencyKey: "order-8812" },
);
// { runId: "wfp-4c548e7b-…", via: "warmed" }run.via is "warmed" when a pre-started instance took the input and "cold" when the pool fell back to create(). Either way, run.runId is a Workflow instance ID you can pass to status and sendEvent.
wf-pool is not on npm yet. It lives in this monorepo as @cf-workflow-pool/wf-pool (main entry) and @cf-workflow-pool/wf-pool/http (HTTP entry). Add it as a workspace dependency:
wf-pool targets Cloudflare Workers and is built on Effect 4.0.1 and Alchemy 2.0.0-beta.81. Both are pre-1.0, so pin the same versions.
- Why a pool
- Mental model
- Define a pool
- Write its body
- Register and host
- Dispatch, read status, send events
- Idempotency
- Serve it over HTTP
- Errors
- Configuration
- API map
- Caveats
- Repository layout
Cloudflare Workflows start slowly. These numbers come from a deployed Worker in a single run, alternating cold and pooled samples, 500 samples per arm:
| Path | First step p50 | p90 | p99 |
|---|---|---|---|
Cold create() (concurrency 1) |
1,117 ms | 1,590 ms | 2,281 ms |
Pooled dispatch() (concurrency 1) |
183 ms | 349 ms | 517 ms |
Cold create() (concurrency 10) |
975 ms | 1,447 ms | 2,703 ms |
Pooled dispatch() (concurrency 10) |
162 ms | 329 ms | 500 ms |
A cold start costs about 400 ms for create() to resolve and roughly another 700 ms before the first step runs. A pooled dispatch pays one Durable Object round trip (~25 ms) to claim an instance plus one sendEvent (~70 ms) to deliver the input.
The method, raw-data provenance, and open questions are in docs/benchmark-findings.md.
A pool is a Workflow whose instances start before anyone needs them:
- Warm. The coordinator creates
Warmedinstances in batches. Each instance runs awf-pool-report-warmedstep, then parks onwaitForEvent("wf-pool-dispatch"). - Claim.
dispatchasks the pool's coordinator, a Durable Object, for a warmed instance. Each instance is handed out once. - Deliver. The input is sent with
sendEvent. The parked run decodes it with the pool's input schema and runs your body. - Replenish. When warmed plus starting instances drop to
replenishAtSize, one batch refills the pool topoolSize.
A claimed instance is one-shot. Instances that are never claimed are retired 30 s before their warmedTimeout and terminated.
Warmed means created, scheduled, and parked. It doesn't mean the isolate is still resident; see Caveats.
A pool definition is light: a name, input and output schemas, and a refill policy. Clients import only this, so they never bundle Workflow bodies or the coordinator.
// checkout-pool.ts
import { WorkflowPool } from "@cf-workflow-pool/wf-pool";
import { Schema } from "effect";
export const CheckoutInput = Schema.Struct({
orderId: Schema.String,
sku: Schema.String,
quantity: Schema.Int,
});
export const Reservation = Schema.Struct({
orderId: Schema.String,
reservationId: Schema.String,
});
export class CheckoutPool extends WorkflowPool<CheckoutPool>()("CheckoutPool", {
input: CheckoutInput, // decodes every dispatch, warmed or cold
output: Reservation, // encodes the result; status() decodes it
poolSize: 10, // instances kept warmed or starting
replenishAtSize: 5, // low-water mark: refill when warmed + starting drops to 5
warmedTimeout: "10 minutes", // how long a warmed instance waits for a dispatch
}) {}Invalid definitions throw when the class is defined, and the error names the pool and every problem:
Workflow pool CheckoutPool has an invalid definition: replenishAtSize must be a whole number
from 0 to poolSize - 1 (9), but is 10. Correct the WorkflowPool definition..make() supplies the Workflow body and belongs only in the host Worker. The outer Effect runs once per isolate and can read init dependencies. The returned function runs for each instance and receives input already decoded by the pool's schema:
// checkout-pool-workflow.ts (bundled only into the host Worker)
import * as Cloudflare from "alchemy/Cloudflare";
import { Effect } from "effect";
import { CheckoutPool } from "./checkout-pool.ts";
import { InventoryClient } from "./inventory-client.ts";
export const checkoutPoolWorkflow = CheckoutPool.make(
Effect.gen(function* () {
const inventory = yield* InventoryClient; // init dependency
return Effect.fn(function* (input) {
// input: { orderId, sku, quantity }
const reservation = yield* Cloudflare.Workflows.task("reserve", inventory.reserve(input));
yield* Cloudflare.Workflows.waitForEvent("payment", {
type: "payment-authorized",
timeout: "1 hour",
});
return reservation; // must match Reservation, checked at compile time
});
}),
);The body's init dependencies (InventoryClient here) become requirements of the Layer that .make() returns.
A registry lists every pool a Worker hosts. Building its layer registers each pool's Workflow and the WorkflowPoolCoordinator Durable Object. A missing .make() Layer is a compile error.
// order-workflow-pools.ts
import { WorkflowPoolRegistry } from "@cf-workflow-pool/wf-pool";
export class OrderWorkflowPools extends WorkflowPoolRegistry<OrderWorkflowPools>()(
"OrderWorkflowPools",
{ checkout: CheckoutPool, refund: RefundPool }, // keys become pools.checkout, /v1/checkout, …
) {}The host Worker lists the coordinator among its Durable Objects and provides every pool's body:
// orders-worker.ts
import { WorkflowPoolCoordinator } from "@cf-workflow-pool/wf-pool";
import * as Cloudflare from "alchemy/Cloudflare";
import { Effect, Layer } from "effect";
export class OrdersWorker extends Cloudflare.Worker<OrdersWorker, {}, WorkflowPoolCoordinator>()(
"OrdersWorker",
) {}
export default OrdersWorker.make(
{ main: import.meta.url },
Effect.gen(function* () {
const pools = yield* OrderWorkflowPools;
// …build fetch
return { fetch };
}).pipe(
Effect.provide(
OrderWorkflowPools.layer.pipe(
Layer.provide(Layer.mergeAll(checkoutPoolWorkflow, refundPoolWorkflow)),
Layer.provide(inventoryClientLayer),
),
),
),
);A Worker hosts at most one registry because the coordinator's class name is fixed. Pool names must be unique within the registry.
Each registry key becomes a client, pools.<key>:
const pools = yield * OrderWorkflowPools;
const run =
yield *
pools.checkout.dispatch(
{ orderId: "order-8812", sku: "sku-1", quantity: 2 },
{ idempotencyKey: "order-8812" }, // one key per logical run
);
// WorkflowPoolDispatched: { runId, via: "warmed" | "cold" }
const status = yield * pools.checkout.status(run.runId);
// { _tag: "Running" }
// | { _tag: "Complete"; output: Reservation }
// | { _tag: "Errored"; error: Option<{ name; message }> }
// | { _tag: "Terminated" }
if (status._tag === "Complete") {
console.log(status.output.reservationId);
}sendEvent delivers an event to a waitForEvent in your body, for example a webhook or an approval:
yield *
pools.checkout.sendEvent(run.runId, {
type: "payment-authorized",
payload: { paymentId: "pay_123" },
});The pool's own wf-pool-dispatch type is refused, so no caller can deliver a second input to a run. Event types that Cloudflare would reject (anything outside ^[a-zA-Z0-9_][a-zA-Z0-9-_]*$) are refused before sending.
Pools start empty. Fill one after deploying, and inspect it at any time:
yield * pools.checkout.warmUp();
// { startedInstanceCount: 10 }
yield * pools.checkout.readInventory();
// { warmedInstanceCount: 10, startingInstanceCount: 0 }warmUp waits for the instances to be created, not for them to report warmed. It does nothing when the pool is full or a refill is in flight.
Every dispatch carries an idempotencyKey (1–255 characters). The coordinator records each key's decision for a day: which warmed instance it claimed, or that it started cold. Cold runs use an instance ID derived from the key. Repeating a dispatch with the same key therefore returns the same run, delivering the input again if the first attempt's outcome was unknown. Retrying after a timeout or a 503 cannot start a second run.
Only a SHA-256 hash of the key is stored, so a key that contains domain data (an order ID) is never persisted as written.
If Cloudflare rejects the input for a claimed instance and it was certainly not delivered (the instance had ended or did not exist), dispatch retries once, on another instance or a cold start.
Note
The guarantee holds while the coordinator answers. A dispatch that started cold because the coordinator was unreachable is deduplicated only against other cold starts with the same key.
WorkflowPoolHttpApi.make(registry) derives an ordinary Effect HttpApi with one group per registry key. It imports only pool definitions, so clients can share it without bundling Workflow bodies:
// order-workflow-pools-http-api.ts (shared by server and clients)
import { WorkflowPoolHttpApi } from "@cf-workflow-pool/wf-pool/http";
export const OrderWorkflowPoolsHttpApi = WorkflowPoolHttpApi.make(OrderWorkflowPools);
// POST /v1/checkout/dispatch dispatch (Idempotency-Key header, body: CheckoutInput)
// GET /v1/checkout/runs/:runId status
// POST /v1/checkout/runs/:runId/events sendEvent (body: { type, payload })
export const OrdersHttpApi = HttpApi.make("orders")
.add(OrdersGroup)
.addHttpApi(OrderWorkflowPoolsHttpApi.prefix("/pools"))
.middleware(AuthMiddleware);Add it to your app's API so pool endpoints share your middleware, auth, client, and OpenAPI spec. Pass the API you actually serve to handlersLayer, so routes pick up its prefix:
const fetch =
yield *
HttpRouter.toHttpEffect(
HttpApiBuilder.layer(OrdersHttpApi).pipe(
Layer.provide(ordersHandlersLayer),
Layer.provide(WorkflowPoolHttpApi.handlersLayer(OrdersHttpApi, OrderWorkflowPools)),
Layer.provide(authMiddlewareLayer),
Layer.provide(Http.Platform),
),
);Another app builds a typed client from the same contract:
const client = yield * HttpApiClient.make(OrdersHttpApi, { baseUrl: "https://orders.example.com" });
const run =
yield *
client.checkout.dispatch({
payload: { orderId: "order-8812", sku: "sku-1", quantity: 2 },
headers: { "idempotency-key": "order-8812" },
});
const status = yield * client.checkout.status({ params: { runId: run.runId } });Or with curl:
curl -X POST https://orders.example.com/pools/v1/checkout/dispatch \
-H 'content-type: application/json' \
-H 'idempotency-key: order-8812' \
-d '{"orderId":"order-8812","sku":"sku-1","quantity":2}'
# 202 {"runId":"wfp-4c548e7b-…","via":"warmed"}Inside a Worker, failures are typed Effect errors (Schema.TaggedError) that you handle by tag. Each message says what happened and what to do next:
const run =
yield *
pools.checkout.dispatch(input, { idempotencyKey: orderId }).pipe(
Effect.catchTags({
// Not retryable: the input does not match the pool's schema.
WorkflowPoolDispatchInputInvalid: (error) =>
Effect.fail(new CheckoutRejected({ orderId, message: error.message })),
// Retry with the same key; it cannot start a second run.
WorkflowPoolWarmedDispatchFailed: () => Effect.fail(new CheckoutUnavailable({ orderId })),
WorkflowPoolColdStartFailed: () => Effect.fail(new CheckoutUnavailable({ orderId })),
}),
);| Operation | Errors |
|---|---|
dispatch |
WorkflowPoolDispatchInputInvalid, WorkflowPoolWarmedDispatchFailed, WorkflowPoolColdStartFailed |
status |
WorkflowPoolRunStatusFailed (RunNotFound | Unclassified), WorkflowPoolRunOutputInvalid |
sendEvent |
WorkflowPoolEventTypeInvalid, WorkflowPoolSendEventFailed (RunFinished | RunNotFound | Unclassified) |
warmUp |
WorkflowPoolRefillFailed, WorkflowPoolCoordinatorUnavailable |
readInventory |
WorkflowPoolCoordinatorUnavailable |
Runs themselves can fail with WorkflowPoolInputInvalid, WorkflowPoolOutputInvalid, or WorkflowPoolRunParamsInvalid. Cloudflare records only a run error's name and message, so status reports these as Errored with the tag as a message prefix.
Over HTTP, every failure uses one envelope with a stable code, an actionable message, variant-specific details, a requestId to correlate with traces, and a truthful retryable. Internal causes and schema issues, which may echo payloads, never reach a response.
{
"code": "WorkflowPoolRunNotFound",
"message": "Pool checkout has no run wfp-missing. Check the run ID; it comes from the dispatch response.",
"details": { "pool": "checkout", "runId": "wfp-missing" },
"requestId": "a47e1617dbfb6c1d",
"retryable": false
}| Status | code |
Retry? |
|---|---|---|
| 400 | WorkflowPoolRequestInvalid |
no |
| 400 | WorkflowPoolDispatchInputInvalid |
no |
| 400 | WorkflowPoolEventTypeInvalid |
no |
| 404 | WorkflowPoolRunNotFound |
no |
| 409 | WorkflowPoolRunFinished |
no |
| 503 | WorkflowPoolDispatchUnavailable |
yes |
| 503 | WorkflowPoolDispatchOutcomeUnknown |
yes, same key |
| 503 | WorkflowPoolRunStatusUnavailable |
yes |
| 500 | WorkflowPoolRunOutputInvalid |
no |
| 500 | WorkflowPoolSendEventOutcomeUnknown |
no |
See the API guide for when each code occurs.
| Field | Meaning |
|---|---|
input |
Effect schema with no service requirements. Decodes every dispatch on both start paths. |
output |
Effect schema with no service requirements. Encodes the run result; status decodes it. |
poolSize |
How many instances the pool keeps warmed or starting. At least 1. |
replenishAtSize |
Low-water mark: when warmed + starting drops to this, one batch refills to poolSize. From 0 to poolSize - 1. |
warmedTimeout |
How long a warmed instance waits for a dispatch, from 1 second to 365 days. Instances retire 30 s before it ends. |
Two names matter when you rename things:
- The registry key (
checkout) names clients and HTTP paths. Renaming it breaks HTTP callers. - The pool class name (
CheckoutPool) names the Workflow and its coordinator. Renaming it deploys a new Workflow and strands the old one's warmed instances.
| Entry | Exports |
|---|---|
@cf-workflow-pool/wf-pool |
WorkflowPool, WorkflowPoolRegistry, WorkflowPoolCoordinator, typed errors, client types |
@cf-workflow-pool/wf-pool/http |
WorkflowPoolHttpApi.make, WorkflowPoolHttpApi.handlersLayer, HTTP error schemas |
Client method (pools.<key>) |
Purpose |
|---|---|
dispatch(input, options) |
Run the pool's Workflow on a warmed instance, or cold when none is warmed |
status(runId) |
Read a run's status, decoding a completed run's output |
sendEvent(runId, event) |
Deliver an event to a waitForEvent in the body |
warmUp() |
Fill the pool to poolSize |
readInventory() |
Count warmed and starting instances |
| Type | Purpose |
|---|---|
WorkflowPoolDefinition |
A pool's schemas and refill policy |
WorkflowPoolBody<I, O> |
A pool's Workflow body: decoded input to output |
WorkflowPoolClient<I, O> |
One pool's client |
WorkflowPoolDispatched |
{ runId, via } returned by dispatch |
WorkflowPoolRunStatus<O> |
Running, Complete, Errored, or Terminated |
WorkflowPoolRunEvent<P> |
{ type, payload } passed to sendEvent |
WorkflowPoolInventory |
{ warmedInstanceCount, startingInstanceCount } |
WorkflowPoolWarmUp |
{ startedInstanceCount } |
- Long-parked instances are slow to wake. In a small probe, instances parked 5 minutes or more took 1.7–2.7 s to resume after their event, slower than a cold start. Instances parked a minute or less resumed quickly. For latency-sensitive pools, prefer a
warmedTimeoutmeasured in minutes, not days. - Pre-started isn't the same as warm. A parked instance skips creation and scheduling, but nothing guarantees its isolate is still resident.
- One region measured. All benchmarks ran from one colo (
ATL) with the coordinator in the same colo. - Pre-1.0 foundations. Effect 4 and Alchemy v2 are still moving; wf-pool pins exact versions.
| Path | What it is |
|---|---|
packages/wf-pool |
The library |
apps/benchmarks |
Cold-start vs pooled-dispatch benchmark driver |
apps/wf-pool-spike |
Throwaway Worker for verifying the library on a deployed stage |
apps/site |
cf-workflow-pool.mulroy.dev |
apps/traces |
pnpm traces, the trace query CLI |
docs/ |
Design, benchmark method, findings, and project standards |
pnpm ready # vp check && vp run -r test