ElementsSignal

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.

The one rule

Broadcast is ephemeral. If a subscriber is offline or restarts during the emit, it does not receive past events. Use live when clients need replay. Use once when exactly one worker must claim the job.

broadcast — fan-out, then miss

signal.broadcast · ephemeral
emit
every active gets a copy · offline misses

fx.emit(orderChanged, …)

  • cache.purgeLocal

    waiting for copy

    subscribed · waiting

  • orders.notifyWatchers

    waiting for copy

    subscribed · waiting

  • search.reindexSku

    offline 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

src/signals/cache.ts
import { signal } from "okengine";
import { z } from "zod";

export const cacheInvalidated = signal.broadcast("cache.invalidated", {
  schema: z.object({ key: z.string() }),
});

Bind a subscriber

src/flows/cache/purge.ts
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

src/flows/skus/[sku]/update.ts
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):

src/signals/catalog.ts
import { signal } from "okengine";
import { z } from "zod";

export const catalogChanged = signal.broadcast("catalog.changed", {
  schema: z.object({ sku: z.string() }),
});
src/flows/cache/invalidate.ts
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:

HelperPatternWho runsReplayTypical use
signal.onceWork queueExactly one competing workerRetries + DLQEmails, fulfillment, sync
signal.broadcastPub/subEvery active subscriberNone (miss if offline)Cache bust, multi-side-effect fan-out
signal.liveSSE tapeHTTP clients via http.liveLast-Event-ID resumeStatus feeds, progress
GuaranteeBroadcastOnceLive
Competing claim / visibility leaseNoYes (default 30s)No
Dead-letter queue pathNoYes (deadLetter)No
Retained history for late joinersNoNoYes
Multiple Flows on one emitYes — each gets a copyOne claimerNot a Flow worker

Options for broadcast

Second argument to signal.broadcast(name, options?). Delivery is the helper name — not an option.

OptionTypeDefaultMeaning
schemaStandard SchemaomittedEmit contract — validated at fx.emit (OKE1250)
optionalbooleanfalseAllow emit with zero subscribers
descriptionstringthe nameConsole / docs blurb
retriesnumber3Declared; not the once lease / DLQ path
deadLetterbooleantrueDeclared; 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.live

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

src/flows/orders/side-effects.ts
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" });
ConceptMeaning
SubscriberAn adopted Flow bound with on(broadcastSignal, flow)
CopyEach subscriber receives the payload independently
IsolationOne handler's failure does not lease-lock siblings
OfflineA 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.

CallRecordsUse
fx.emit(signal, payload?)emitsPublish to every active subscriber
fx.emit(signal, payload, { key })emitskey is for once ordering — ignore for broadcast fan-out
src/flows/catalog/create.ts
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:

src/flows/cache/on-catalog.ts
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.

NeedUse instead
Exactly one worker processes the jobonce
Browser / SSE resume after disconnectlive
Durable multi-step work with journalDurable Workflows
HTTP SSE firehose on a pathhttp.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.

Troubleshooting

Learn more

Next

On this page