Skip to content

About

A pre-started instance pool for Cloudflare Workflows (Effect + Alchemy). Results: https://cf-workflow-pool.mulroy.dev

Resources

Stars

64 stars

Watchers

0 watching

Forks

Repository files navigation

cf-workflow-pool

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 after create().
  • 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 Effect HttpApi with 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.

Install

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:

// package.json
{
  "dependencies": {
    "@cf-workflow-pool/wf-pool": "workspace:*",
    "alchemy": "catalog:",
    "effect": "catalog:",
  },
}

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.

Contents

Why a pool

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.

Mental model

A pool is a Workflow whose instances start before anyone needs them:

  1. Warm. The coordinator creates Warmed instances in batches. Each instance runs a wf-pool-report-warmed step, then parks on waitForEvent("wf-pool-dispatch").
  2. Claim. dispatch asks the pool's coordinator, a Durable Object, for a warmed instance. Each instance is handed out once.
  3. Deliver. The input is sent with sendEvent. The parked run decodes it with the pool's input schema and runs your body.
  4. Replenish. When warmed plus starting instances drop to replenishAtSize, one batch refills the pool to poolSize.

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.

Define a pool

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.

Write its body

.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.

Register and host

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.

Dispatch, read status, send events

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.

Idempotency

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.

Serve it over HTTP

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"}

Errors

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.

Configuration

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.

API map

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

Public types

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 }

Caveats

  • 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 warmedTimeout measured 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.

Repository layout

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

Documentation

About

A pre-started instance pool for Cloudflare Workflows (Effect + Alchemy). Results: https://cf-workflow-pool.mulroy.dev

Resources

Stars

64 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages