Overview
Data in motion — once, broadcast, and live SSE tapes under one typed declaration with HTTP-shaped delivery helpers.
Signal is how your backend moves data when the producer should not wait. An email job, a cache bust across instances, and a browser status feed share one handle shape — only the helper changes: signal.once, signal.broadcast, or signal.live.
For developers wiring async work on okengine — declare the physics, emit with fx.emit, bind workers with on(handle, flow("name", { do })).
The one rule
Declare every signal with signal.once, signal.broadcast, or signal.live as an
exported const. Bind with on(handle, flow("name", { do })). Physics and the emit
schema live on the Signal; the worker inherits the payload type, not flow.in.
delivery — pick the physics
required · no defaultonceQueue — competing consumers
Exactly one claims · retries + DLQ
At-least-once jobs: emails, payment sync (idempotent consumers)
broadcastPub/sub — every subscriber
Each subscriber id gets its own copy
Cache invalidation, cross-service events
liveStream — retained feed
Late bus.live() replays full history
Status feeds, progress — server-side today
Smallest Example
One handle, three independent uses
orderPlaced is a shared const. Declare it, bind a worker, and emit from a producer — different
files, different people, any order. None of these is a prerequisite step for the others.
Declare
import { signal } from "okengine";
import { z } from "zod";
export const orderPlaced = signal.once("orders.placed", {
schema: z.object({
orderId: z.string(),
amount: z.number(),
userId: z.string(),
}),
retries: 5,
deadLetter: true,
});Bind a worker
import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";
export const fulfill = on(
orderPlaced,
flow("orders.fulfill", {
do: async ({ orderId }, fx) => {
await fx.call(chargeAndShip, { orderId });
},
}),
);Emit
// Inside any Flow — need not live next to the worker:
await fx.emit(orderPlaced, {
orderId: "ord_99",
amount: 150,
userId: "usr_1",
});The compiler records emits: ["orders.placed"] on the producer. The HTTP request does not wait
for the worker.
Live is not a worker
signal.live is an HTTP SSE tape. Expose it with
http.live — do not bind on(liveSignal, flow) as a
competing consumer.
A string fx.emit("name", payload) still runs (runtime schema still applies) but does not
type-check. Import the exported const.
Progressive Patterns
Same helpers + fx.emit from a queue job to a fan-out and a browser feed:
Competing workers — exactly one claims each message. Failed attempts retry, then dead-letter
when deadLetter is true (default):
import { signal } from "okengine";
import { z } from "zod";
export const emailTask = signal.once("tasks.email", {
schema: z.object({ to: z.string().email(), body: z.string() }),
retries: 3,
deadLetter: true,
});import { on, flow } from "okengine";
import { emailTask } from "@/signals/email";
import { rawEmail } from "@/channels/email";
export const processEmail = on(
emailTask,
flow("workers.email", {
do: async ({ to, body }, fx) => {
await fx.send(rawEmail, { to, body });
},
}),
);Delivery Physics
| Helper | Pattern | Consumer | Replay | Typical use |
|---|---|---|---|---|
signal.once | Work queue | Competing workers, lease lock | Retries + DLQ | Emails, fulfillment, sync |
signal.broadcast | Pub/sub | Every active subscriber | None (miss if offline) | Cache invalidation, fan-out |
signal.live | SSE tape | HTTP clients via http.live | Last-Event-ID resume | Status feeds, progress |
Competing consumers (once vs broadcast)
This is the most common mix-up. signal.once is a work queue: exactly one worker claims each
message. It is not fan-out.
Three differently-named Flows bound to the same once signal, then one emit:
import { on, flow } from "okengine";
import { orderPlaced } from "@/signals/orders";
export const charge = on(
orderPlaced,
flow("orders.charge", {
do: async ({ orderId }, fx) => {
await fx.call(chargeOrder, { orderId });
},
}),
);
export const ship = on(
orderPlaced,
flow("orders.ship", {
do: async ({ orderId }, fx) => {
await fx.call(shipOrder, { orderId });
},
}),
);
export const notify = on(
orderPlaced,
flow("orders.notify", {
do: async ({ orderId }, fx) => {
await fx.call(notifyOrder, { orderId });
},
}),
);await fx.emit(orderPlaced, { orderId: "ord_99", amount: 150, userId: "usr_1" });| Expectation | Real result |
|---|---|
| All three Flows run | No. Exactly one of the three runs |
Always orders.charge (first on) | No. The winner is whichever claim lands first — not a fixed Flow |
| A sticky assignment to one Flow | No. There is no owner — only an exclusive claim per message |
Consequence: if every bound Flow should independently receive its own copy, that is
signal.broadcast — switch the declaration. That is the correct
fix for this exact mistake.
Two or more different Flow definitions on the same once signal fail OKE1071.
Cause: Once signal "{signal}" is bound to more than one Flow ({flows}).
Load-balancing competing consumers is the same Flow on many process replicas — still one
on() in source.
Fix: Use signal.broadcast if each flow should independently receive this event, or bind only one flow if these should compete for the same work.
The Capabilities of Signal
Once
Competing workers, visibility leases, retries, dead letters, and per-key ordering.
Broadcast
Ephemeral fan-out across subscribed Flows — no retained tape.
Live
Retained event tapes over HTTP SSE with Last-Event-ID resume.
Options Reference
Optional second argument to signal.once / signal.broadcast / signal.live. Delivery is the helper name — not an option.
| Option | Type | Default | Meaning |
|---|---|---|---|
schema | Standard Schema | omitted | Emit contract — enforced at fx.emit (OKE1250 on mismatch). Workers inherit the payload; do not put in on flow(). |
retries | number | 3 | Extra attempts after the first (retries + 1 total) — once path |
deadLetter | boolean | true | Keep exhausted once messages; false marks them delivered |
optional | boolean | false | Allow emit with zero subscribers |
retention | { maxAge?, maxCount? } | unbounded | signal.live only — type error on once / broadcast |
description | string | the name | Console / docs blurb |
Consequence: deadLetter is a boolean flag, not a queue name string.
Emit through fx
Detailed section
If you only need fx.emit(signal, payload), jump to the table. Emit commits the outbox when the
call resolves. The producer run id is stamped as parentRunId for Console trace chains.
| Call | Records | Use |
|---|---|---|
fx.emit(signal, payload?, { key? }) | emits | Publish; { key } serializes once per key |
fx.deadLetters(signal) | reads signal:<name> | Inspect exhausted once messages |
fx.live(signal, { match? }) | reads signal:<name> | Server SSE body for a live tape |
Invalid schema payloads fail at emit with OKE1250 ("{resource}": {detail}) before any
consumer runs. Cross-signal fx.deadLetters / fx.live without a declared read throws
OKE1001.
await fx.emit(orderPlaced, { orderId: "ord_1", userId: "usr_1" }, { key: "usr_1" });Omit key for a pure competing pool with no ordering.
Per-environment drivers
Protocol ids: memory · redis · postgres · nats. Defaults (when oke.config.ts omits
drivers.signal):
| Env | Default | Boot today |
|---|---|---|
dev | redis | Emit relays to Redis; consume / live / drain use a process-local outbox |
test | memory | In-process bus |
prod | redis | Same redis honesty as dev |
export default defineConfig({
drivers: {
signal: { test: "memory", prod: "redis" },
},
});postgres and nats fail loud at boot until a native bind ships — never silently fall back to
memory. Prefer memory for tests; pin redis when Compose provides Redis.
Troubleshooting
retention: { maxAge, maxCount } is live-only. Drop it on queue / pub-sub Signals, or switch
to signal.live.
Cause: Flow "{flow}" emits signal "{resource}" with no subscriber.
Fix: bind on(signal, flow) or set { optional: true }.
Cause: "{resource}": {detail} from the Standard Schema issues. Fix the payload or the schema
on the Signal. The consumer never ran.
Cause: Flow "{flow}" is defined twice. Two flow("…") strings collide. Give at least one a
distinct name.
Cause: A signal flow on "{trigger}" has no name.
Fix: pass an explicit name — on(handle, flow("orders.fulfill", { do })).
Cause: Once signal "{signal}" is bound to more than one Flow ({flows}). Use signal.broadcast
if each Flow should independently receive this event, or bind only one Flow. See Competing
consumers.
Those ids are reserved but not bound for production yet. Use "memory" or "redis", or inject a
custom elements.signal runtime.
Boot warns that redis emit relays to Redis while consume / live / drain stay process-local. Multi-instance competing consumers need a shared durable outbox path (or a single consumer instance) until Redis Streams consume ships.
Learn more
- Once — leases, retries, partition keys, DLQ
- Broadcast — ephemeral fan-out
- Live — SSE tapes and retention
- Consumers —
on(signal)workers next to Clock / CDC - HTTP · Live Streams —
http.liveexposure - fx —
fx.emit,fx.deadLetters,fx.live - Client —
api.livefor browsers - Errors — OKE1070 · OKE1071 · OKE1072 · OKE1240 · OKE1250 · OKE1210