Deploy: API/Worker split
The framework’s default deploy is a single container (runSingleInstance: true): one process serves HTTP, consumes both job lanes, and applies every multi-stream projection. The split topology moves the heavy background work into a second process — a worker that runs no HTTP surface, only the event-dispatcher and the BullMQ job runner.
You need the split when the worker lane does heavy, long-running, or blocking work that must not share a process with request handling: batch exports, IMAP ingestion, embeddings, image processing. The API stays responsive, the worker scales independently.
The topology
Section titled “The topology”Two processes, one feature, one shared Postgres + Redis. The API process is enqueuer-only; the worker owns the read side.
flowchart LR client[Client] -->|HTTP /api/*| api[API process<br/>runProdApp runSingleInstance: false] api -->|"writes events"| pg[(Postgres)] api -->|"enqueues BullMQ worker-lane jobs"| redis[(Redis queue)] redis -->|"consumes"| worker[Worker process<br/>runWorkerApp] worker -->|"applies multiStreamProjections"| pg worker -->|"writes results back via its dispatcher"| pg
The job’s journey, animated — note the write-back leg goes through the worker’s dispatcher, not the job’s context:
Two entrypoints, one feature
Section titled “Two entrypoints, one feature”Both processes build the same feature array, so the registry, schema, and migrations stay identical. They differ only in what they run:
// API process — `runSingleInstance: false` makes this process API-only:// HTTP + event writes + BullMQ ENQUEUE, no worker-lane consumption, no// multiStreamProjection application. A dedicated worker MUST run next to// it (bin/worker.ts) or the read-side stays empty.import { runProdApp } from "@cosmicdrift/kumiko-server-runtime";import { createApiWorkerSplitFeature } from "../src/feature";
await runProdApp({ features: [createApiWorkerSplitFeature()], runSingleInstance: false, // worker-lane queue prefix; must match bin/worker.ts. jobs: { queueNamePrefix: "api-worker-split" },});runSingleInstance: false is the flag that makes this process API-only: it stops applying multi-stream projections and stops consuming job queues locally — it keeps only the BullMQ enqueuer so event-triggered jobs reach the queue at all.
// Worker process — consumes the worker-lane BullMQ queue and applies the// multiStreamProjections the API skipped. No HTTP surface.//// wireComponents runs after the entrypoint is up and hands over// `dispatchSystemWrite` — a dispatcher-scoped system write. The job's// write-bridge is wired here (fw#1717): JobContext has no write/query, so// background components persist through this dispatcher instead.import { runWorkerApp } from "@cosmicdrift/kumiko-server-runtime";import { createApiWorkerSplitFeature, SAMPLE_TENANT_ID, setOrderFulfillWrite,} from "../src/feature";
await runWorkerApp({ features: [createApiWorkerSplitFeature()], jobs: { queueNamePrefix: "api-worker-split" }, wireComponents: async ({ dispatchSystemWrite }) => { setOrderFulfillWrite(({ handlerQn, payload }) => dispatchSystemWrite({ handlerQn, payload, tenantId: SAMPLE_TENANT_ID }), ); },});wireComponents runs after the worker is up and hands over dispatchSystemWrite — a dispatcher-scoped system write (see the write-back section below).
The sharp edge: a split without a worker is a broken app
Section titled “The sharp edge: a split without a worker is a broken app”With runSingleInstance: false the API process applies no multi-stream projections and consumes no worker-lane jobs. If you deploy the API without a worker next to it, the failure is silent: writes succeed, entity rows appear, but every async read model stays empty and every worker-lane job sits in the queue forever. This was a production incident on 2026-06-11.
The integration test in the recipe asserts exactly this — the read side stays empty until the worker joins:
// recipes-api-worker-split/src/__tests__/feature.integration.test.ts (excerpt)// API-only: the write lands, but read_order_activity stays EMPTY.await Bun.sleep(500);const activity = await selectMany(testDb.db, orderActivityTable, {});expect(activity).toHaveLength(0);
// …and once the worker joins, the projection applies:await waitFor(async () => { const activity = await selectMany(testDb.db, orderActivityTable, {}); expect(activity.some((row) => row.orderKey === "Globex Ltd")).toBe(true);});Treat “API without worker” as a topology error — the recipe’s README.md run instructions start both processes explicitly, and kumiko schema apply must run before either process starts.
Writing results back from a job
Section titled “Writing results back from a job”JobContext deliberately has no write/query — a background job must not bypass the write path (idempotency, PII, transition guards). The app wires a write-bridge into the worker at boot instead (fw#1717), the same pattern as inbound-mail-foundation/watch-supervisor:
// --- The write-bridge (fw#1717) -------------------------------------------// JobContext deliberately has no write/query — a background job must not// bypass the write path (idempotency, PII, transition guards). The app// therefore wires a component with the worker's dispatcher at boot and the// job calls it. Same pattern as inbound-mail-foundation's watch-supervisor.type FulfillWrite = (args: { readonly handlerQn: string; readonly payload: Record<string, unknown>; readonly tenantId: string;}) => Promise<unknown>;
let fulfillWrite: FulfillWrite | undefined;
/** Called by bin/worker.ts (wireComponents) with dispatchSystemWrite. */export function setOrderFulfillWrite(fn: FulfillWrite): void { fulfillWrite = fn;}
export function createApiWorkerSplitFeature() { return defineFeature("orders", (r) => { r.crud("order", orderEntity, { write: openAccess, read: openAccess }); r.crud("fulfillment", fulfillmentEntity, { write: openAccess, read: openAccess });
// Heavy follow-up work, pinned to the worker lane: the API process only // enqueues (its BullMQ client, no consumer), the worker executes. r.job( "process-order", { trigger: { on: "orders:write:order:create" }, runIn: "worker", }, async (payload) => { if (fulfillWrite === undefined) { throw new Error( "process-order: no fulfill-write bridge wired — bin/worker.ts must call setOrderFulfillWrite at boot", ); } const customerName = payload["customerName"] as string; await fulfillWrite({ handlerQn: "orders:write:fulfillment:create", payload: { orderKey: customerName, carrier: "DHL", label: `label-${customerName}`, }, tenantId: SAMPLE_TENANT_ID, }); }, );The job calls the bridge with a plain write-handler QN; dispatchSystemWrite routes it through the worker’s command-dispatcher as a system user, so the fulfillment write gets the same validation, event append, and PII handling as any HTTP write.
Limits of the split
Section titled “Limits of the split”- SSE does not cross process boundaries (
fw#1718). The API process’s SSE broker streams from its own instance cursor; a worker process holds no broker. Realtime consumers must connect to the API process, never the worker. - Multiple workers do not coordinate shared resources (
fw#1719). Two worker replicas consuming the same queue each apply the same multi-stream projections (safe —SKIP LOCKEDon the consumer cursor) but nothing coordinates externally-owned resources like an IMAP inbox. Give shared-resource workers their own queue lane or a leader-election component.
Try it
Section titled “Try it”The API/Worker-Split recipe ships the full feature, both binaries, and the integration test — run it against local Postgres + Redis and watch the read side come alive only when the worker joins.