Broadcast
Ephemeral fan-out — every active subscriber gets a copy; offline listeners miss the event.
signal.broadcast delivers each emission to every subscribed Flow at once. Use it when every
active listener should react and you do not need a retained history.
For developers invalidating caches or syncing in-process state — declare the Signal, bind one or
more on(signal, flow) subscribers, emit with fx.emit.
broadcast — fan-out, then miss
signal.broadcast · ephemeralemitfx.emit(orderChanged, …)
cache.purgeLocalwaiting for copy
subscribed · waiting
orders.notifyWatcherswaiting for copy
subscribed · waiting
search.reindexSkuoffline at emit
Restart does not replay broadcast history.
No competing claim — each subscribed Flow runs with its own copy. A failure in one handler does not lease-lock siblings. Use signal.live when a late joiner must catch up.
Smallest Example
One handle, three independent uses
cacheInvalidated is a shared const. Declare it, bind any number of subscribers, and emit —
different files, any order. Bind and emit do not have a required sequence.
Declare
import { signal } from "okengine";
import { z } from "zod";
export const cacheInvalidated = signal.broadcast("cache.invalidated", {
schema: z.object({ key: z.string() }),
});Bind a subscriber
import { on, flow } from "okengine";
import { cacheInvalidated } from "@/signals/cache";
export const purgeLocalCache = on(
cacheInvalidated,
flow("cache.purgeLocal", {
do: async ({ key }, fx) => {
await fx.store.kv.delete(key);
},
}),
);Multiple Flows may bind the same handle — every one gets a copy. That is fan-out, not a race. See Once · Competing consumers when you meant a work queue instead.
Emit
import { on, flow, http } from "okengine";
import { z } from "zod";
import { cacheInvalidated } from "@/signals/cache";
export const update = on(
http.patch({
in: z.object({ sku: z.string(), title: z.string().min(1) }),
}),
flow({
do: async ({ sku, title }, fx) => {
// … persist the change …
await fx.emit(cacheInvalidated, { key: `sku:${sku}` });
return { sku, title };
},
}),
);The compiler records emits: ["cache.invalidated"] on the producer. Emit resolves when the
outbox commits — the HTTP request does not wait for every subscriber to finish.
Same handle everywhere
Import the declared Signal handle (or the same name) in every subscriber and producer. A typo in the name creates a different Manifest entry — fan-out never crosses names.
Progressive Patterns
Explore broadcast from one listener to multi-Flow fan-out, optional hooks, and schema-checked payloads:
One Flow subscribed — still broadcast physics (no competing claim, no once DLQ path):
import { signal } from "okengine";
import { z } from "zod";
export const catalogChanged = signal.broadcast("catalog.changed", {
schema: z.object({ sku: z.string() }),
});import { on, flow } from "okengine";
import { catalogChanged } from "@/signals/catalog";
export const invalidate = on(
catalogChanged,
flow("cache.invalidate", {
do: async ({ sku }, fx) => {
await fx.store.kv.delete(`sku:${sku}`);
},
}),
);Delivery Reference
How broadcast sits next to the other helpers when you are choosing physics:
| Helper | Pattern | Who runs | Replay | Typical use |
|---|---|---|---|---|
signal.once | Work queue | Exactly one competing worker | Retries + DLQ | Emails, fulfillment, sync |
signal.broadcast | Pub/sub | Every active subscriber | None (miss if offline) | Cache bust, multi-side-effect fan-out |
signal.live | SSE tape | HTTP clients via http.live | Last-Event-ID resume | Status feeds, progress |
| Guarantee | Broadcast | Once | Live |
|---|---|---|---|
| Competing claim / visibility lease | No | Yes (default 30s) | No |
| Dead-letter queue path | No | Yes (deadLetter) | No |
| Retained history for late joiners | No | No | Yes |
| Multiple Flows on one emit | Yes — each gets a copy | One claimer | Not a Flow worker |
Options for broadcast
Second argument to signal.broadcast(name, options?). Delivery is the helper name — not an
option.
| Option | Type | Default | Meaning |
|---|---|---|---|
schema | Standard Schema | omitted | Emit contract — validated at fx.emit (OKE1250) |
optional | boolean | false | Allow emit with zero subscribers |
description | string | the name | Console / docs blurb |
retries | number | 3 | Declared; not the once lease / DLQ path |
deadLetter | boolean | true | Declared; broadcast does not use once DLQ |
retention is a type error on signal.broadcast — that option is live-only:
signal.broadcast("…"): retention is only valid with signal.liveFan-out Physics
Detailed section
If you only need one subscriber, jump to Emit. Fan-out is the distinctive physics: one emit, N independent Flow runs, no competing claim.
Bind any number of Flows with on(theSameSignal, flow(...)). At dispatch, every matching
binding runs with the same payload.
import { on, flow } from "okengine";
import { orderChanged } from "@/signals/orders";
export const bustOrderCache = on(
orderChanged,
flow("orders.bustCache", {
do: async ({ orderId }, fx) => {
await fx.store.kv.delete(`order:${orderId}`);
},
}),
);
export const notifyOrderWatchers = on(
orderChanged,
flow("orders.notifyWatchers", {
do: async ({ orderId }, fx) => {
await fx.call(pushOrderWatchers, { orderId });
},
}),
);await fx.emit(orderChanged, { orderId: "ord_42", kind: "placed" });| Concept | Meaning |
|---|---|
| Subscriber | An adopted Flow bound with on(broadcastSignal, flow) |
| Copy | Each subscriber receives the payload independently |
| Isolation | One handler's failure does not lease-lock siblings |
| Offline | A process that was down at emit time does not get a replay |
Consequence: treat broadcast handlers as best-effort side effects. If the work must run
exactly once with retries and a DLQ, declare a separate signal.once for that job.
Emit and Effects
Detailed section
If you only need fx.emit(signal, payload), jump to the example. Emit commits the outbox when the
call resolves. Subscribers run asynchronously afterward.
| Call | Records | Use |
|---|---|---|
fx.emit(signal, payload?) | emits | Publish to every active subscriber |
fx.emit(signal, payload, { key }) | emits | key is for once ordering — ignore for broadcast fan-out |
import { on, flow, http } from "okengine";
import { z } from "zod";
import { catalogChanged } from "@/signals/catalog";
export const create = on(
http.post({
in: z.object({ sku: z.string(), title: z.string().min(1) }),
}),
flow({
do: async ({ sku, title }, fx) => {
// … write …
await fx.emit(catalogChanged, { sku });
return { sku, title };
},
}),
);The producer WideEvent / run id is stamped as parentRunId for Console trace chains. Schema
mismatches fail at emit (OKE1250) before any subscriber runs.
Subscribers
Each verb of fan-out is a normal Flow. Bind with on(signal, flow(...)) — same species as
Consumers.
Drop hot keys when the source of truth changes:
import { on, flow } from "okengine";
import { catalogChanged } from "@/signals/catalog";
export const onCatalogCache = on(
catalogChanged,
flow("cache.onCatalog", {
do: async ({ sku }, fx) => {
await fx.store.kv.delete(`sku:${sku}`);
await fx.store.kv.delete(`sku:${sku}:meta`);
},
}),
);Choosing Physics
Detailed section
Pick physics from the guarantee you need. Broadcast is the wrong tool when work must be claimed once, or when a late client must catch up.
| Need | Use instead |
|---|---|
| Exactly one worker processes the job | once |
| Browser / SSE resume after disconnect | live |
| Durable multi-step work with journal | Durable Workflows |
| HTTP SSE firehose on a path | http.live |
Broadcast does not use the once visibility lease. There is no competing claim and no
dead-letter queue for exhausted retries in the queue sense — see
Once · Retries.
Cause: Flow "{flow}" emits signal "{resource}" with no subscriber.
Fix: bind at least one on(signal, flow) or set { optional: true }.
Cause: "{resource}": {detail}. Fix the payload or the schema. No subscriber ran.
Expected. Restarted processes do not replay broadcast history. Emit again after boot if the
listener must refresh state, or switch to live for a retained tape.
Those fields exist on the shared options type (defaults 3 / true) so Console and Manifest stay
uniform. Broadcast delivery does not follow the once lease + DLQ path — do not expect
fx.deadLetters(broadcastSignal) to behave like a queue operator surface.
Throws at declare: signal.broadcast("…"): retention is only valid with signal.live. Cap a
client-visible tape with signal.live({retention}) instead.
Troubleshooting
Confirm both Flows import the same Signal handle / name and are adopted into the app. Names must
match the Manifest entry exactly. A second signal.broadcast("catalog.changed") with a different
spelling is a different signal.
Broadcast has no tape. Re-emit on boot, hydrate from Store, or use
signal.live.
Cause: Flow "{flow}" emits signal "{resource}" with no subscriber.
No subscriber is bound. Add on(signal, …) or { optional: true }.
Cause: "{resource}": {detail}. Align the payload with schema — no subscriber started.
Cause: A signal flow on "{trigger}" has no name.
Fix: pass an explicit name — on(handle, flow("cache.purgeLocal", { do })).
Use signal.once. Broadcast fan-out is not the lease + DLQ path documented under
Once.
You passed retention to signal.broadcast. Drop it, or switch the helper to signal.live if
you need a retained SSE tape.
Broadcast is for Flow subscribers, not browsers. Expose a
signal.live tape with
http.live (or a gated GET .live(signal)).
Learn more
- Signal Overview — delivery matrix and drivers
- Once — competing workers and DLQ
- Live — retained SSE
- Consumers — binding
on(signal) - HTTP · Live Streams — when the consumer is a browser
- fx —
fx.emit - Errors — OKE1072 · OKE1240 · OKE1250