01 / Announce what happened
The save handler knows too many panels.
Calling badge.refresh() after saving is a clear first design. You can follow the
call, see its failure, and know when it finishes. With one stable consumer, that visibility is
useful.
The pressure appears when the consumers are optional and change over time. Each new one is another edit to Save, and each one that closes is a reference Save must forget. Storing a draft has quietly acquired a second job: bookkeeping for whoever is listening right now. Observer covers the case where those consumers register directly with the thing that changes; here Save will not hold the list at all.
Publish / subscribe lets a publisher announce a message through an intermediary, and lets
subscribers register for the messages they care about. Here the intermediary is a small broker. A topic, such as draft.saved, names the kind of event. Save knows that topic and the message
format; it does not name Badge or Activity. That intermediary is the pattern’s defining
move. The per-subscription inboxes and explicit reads below make delivery visible; publish /
subscribe can also deliver through immediate callbacks or network messaging.
Think of a mailing list: the sender addresses the list, and the list distributes a copy to its members. The analogy covers membership and copies; storage, retries, and someone who joins tomorrow are separate promises.
“Draft note-1 was saved.”
The editor owns when that statement is true. It supplies the topic and payload.
One entry per subscription.
The broker owns registration, matching, admission, and removing subscriptions.
Read and react independently.
Each panel owns its subscription and what it does with a message. Reading one copy leaves the others alone.
This moves knowledge about interested panels out of the publisher. It keeps a different
connection: both ends must agree on what draft.saved means. A topic is still a contract,
even when the compiler cannot follow it as a direct function call.
02 / See the shape
First route the message. Then own its lifetime.
The basic form shows the routing loop inside a broker: append the payload to every matching inbox. Its caller supplies those inboxes. It leaves registration, capacity, and cleanup out so you can see the copying relationship.
The useful version owns its subscriptions. subscribe returns an ID, publish addresses a topic, drain reads and empties one inbox, and unsubscribe removes it. The editor’s publication no longer needs the inbox collection.
The routing mechanism inside a broker: every inbox whose topic matches gets an entry.
export interface Inbox {
topic: string;
pending: string[];
}
// The routing mechanism inside a broker: every matching inbox gets an entry.
// This small form leaves registration, capacity, and cleanup to its caller.
export function publishBasic(topic: string, body: string, inboxes: Inbox[]): void {
for (const inbox of inboxes) {
if (inbox.topic === topic) inbox.pending.push(body);
}
} type Inbox struct {
Topic string
Pending []string
}
// The routing mechanism inside a broker: every matching inbox gets an entry.
// This small form leaves registration, capacity, and cleanup to its caller.
func PublishBasic(topic, body string, inboxes []Inbox) {
for i := range inboxes {
if inboxes[i].Topic == topic {
inboxes[i].Pending = append(inboxes[i].Pending, body)
}
}
} Our contract gives each subscription a FIFO inbox: messages leave in their arrival order. If that inbox is full, discard the new message for that subscription and preserve its older entries. Other matching inboxes still accept their copies if they have room.
Accepted means enqueued, not processed. The report lists accepted and dropped subscription IDs in registration order. An empty report is valid: nobody was listening to that topic. A late subscriber starts empty, and unsubscribing discards unread entries. There is no history to replay.
Every implementation matches nonempty topics exactly, without trimming or wildcard expansion. Empty payloads are valid strings. Publishing the same payload twice produces two entries if there is room. The shared cases check these rules, failed operations, and the state left after each step.
Reading the TypeScriptMap, arrays, and ownership
Map<number, Subscription> stores each subscription under its ID and iterates
in insertion order. The returned ID lets a panel read or remove its own subscription without
passing its identity to publishers.
Every subscription gets a new pending array. Strings are immutable, so
sharing string values does not share a mutable payload. drain returns the
old array and installs a new one; changing the returned array cannot change the broker’s
unread messages. snapshot copies records and arrays for the inspector.
Invalid topics and unknown reads throw. The constructor also rejects a nonpositive or
nonintegral capacity. finally in the call site removes the subscriptions when
the example’s work ends. A mounted panel would pair setup with its own cleanup instead.
Reading the GoSlices and explicit errors
[]Subscription keeps registration order. Methods that change the broker use
a pointer receiver, *Broker. Capacity has type int;
construction rejects values below one.
A slice describes a backing array. Drain detaches the old message slice and
installs an empty one so later publications cannot write into a consumer’s returned
buffer. Snapshot copies pending slices for the same reason.
Unsubscribe shifts later entries, clears the old tail to release its
references, and shortens the slice. defer in the call site pairs a successful
subscription with cleanup. Errors are returned explicitly; this example provides no locking
for concurrent goroutines.
03 / Follow each copy
One publication can have different outcomes.
Predict first: Badge and Activity both listen to draft.saved. Publish note-1, change the payload to note-2, and publish again. Both
inboxes are now full. Read only Activity, then publish note-3. Which inbox
accepts it? Which messages does Badge keep?
The lab runs the TypeScript broker shown above whichever comparison language you pick; the native implementation is verified against the same cases. Each inbox holds two messages, and nothing reads it until you ask.
One announcement. Separate inboxes.
Publish three times without reading. Each inbox can hold two messages.
Ready. Two subscriptions match draft.saved; one matches draft.deleted.
Badge
- Empty. Only future matching publications can arrive.
No read yet.
Activity
- Empty. Only future matching publications can arrive.
No read yet.
Trash panel
- Empty. Only future matching publications can arrive.
No read yet.
A topic change replaces that subscription and discards its unread inbox. These are separate subscriptions, so each matching one gets its own queued entry.
Activity has room for note-3. Badge keeps note-1 and note-2 and drops the new copy. The Trash panel hears nothing while it listens to draft.deleted. The broker has routed a fact; it has not completed a workflow
across these panels.
Now unsubscribe Badge, publish again, and subscribe it once more. Its new inbox is empty. Compare that with leaving a subscription active but unread: an active inbox keeps accepted entries until a read or unsubscribe, while an absent subscription receives nothing.
04 / Try the decision
Does one slow subscriber stop everyone?
The pattern name alone cannot answer this. Use the admission rule in this implementation, then think about what a publisher could safely infer from the result.
05 / Give it a real job
The producer owns the truth of the event.
In an editor, the save operation validates and stores the draft first. After that succeeds,
it can announce draft.saved with the draft ID. A mounted Activity panel subscribes;
on reading a message, it refreshes its own view. Closing the panel removes that subscription.
Adding a word-count panel now means registering a new consumer of the existing event. Save stays the same. Changing the event to mean “save requested” would affect every consumer, however: a request can fail, while “saved” describes a completed fact. Names and payload schemas need owners and coordinated changes.
Keep required work in a path that can report its outcome. If validation, storage, or an audit write must succeed for Save to succeed, making that work an optional listener loses the promise. A direct operation or facade can coordinate it explicitly.
Give the broker a deliberate lifetime, such as one editor session. Inject that instance into publishers and consumers. A process-wide singleton in a server-rendered application could mix unrelated users or requests.
What changes when this leaves the lab?Capacity, recovery, and concurrency
- Capacity: two messages per inbox bounds an entry count, not total bytes or the number of subscribers. Large payloads and forgotten subscriptions still consume memory. Choose payload and subscriber limits, observe drops, and decide whether to discard, block, disconnect, or persist when consumers fall behind.
- Processing: draining removes messages before any application work runs. A consumer failure afterward loses those entries unless the consumer keeps and retries them itself.
- Recovery: these optional panels can reload the current draft when they mount or detect missed updates. If every historical event matters, use a mechanism with retention and an explicit replay contract.
- Commit and announce: saving to a database and publishing to a separate service are two actions. A crash between them can leave a saved draft with no announcement. A transactional outbox records the pending event in the same database transaction as the save; a separate sender delivers it. That design still needs retry and duplicate-handling rules. AWS explains the outbox contract and failure cases.
- Concurrency: this broker is used sequentially and invokes no callbacks. Threads, network clients, or handlers that publish during delivery introduce their own ordering and mutation decisions; inbox order is not a global processing order.
Build UIs?A browser channel never hands a message back to the object that posted it. Signing out every open tab makes that your design.
Where it already is in your components
You may already follow this rule if you have kept a setting in sync across tabs: the tab
that changes the setting updates its own state, and the storage listener is only for the others. The HTML Standard’s storage broadcast reaches every same-origin Storage object except the one that made the change,
so in our Chromium 153 run the event fired in the other tab and never in the tab that
called setItem.
BroadcastChannel is the browser running publish / subscribe for you, with the
same rule. The channel name is the topic: every new BroadcastChannel('session') in the same origin subscribes, and postMessage publishes to the rest. The HTML Standard collects the matching channel objects and then says “Remove source from destinations.” So the
object that posted never hears its own message, while a second object with that name in the
same tab does. (“Same origin” is the short version: MDN says
contexts must share a storage partition, so an iframe from your origin embedded on another site
does not hear your top-level tabs.)
In our run, each message arrived as a task after postMessage returned, in the
order the channels were created. A listener that threw did not stop the next one, and a
channel closed before its turn received nothing. A misspelled name is just another
channel: the message reached nobody, and nothing reported it, not even the empty report
our broker returns. The data is structured-cloned at the call, so posting a function
throws DataCloneError right there.
When you have to own it
Now someone signs out in one tab while three more tabs of your app stay open. The server has ended the session, but those pages still show the account and hold its data in memory. You announce “signed out” so they clear it, and the decisions our broker made become yours.
// Every tab must use this exact name. A misspelled name opens a different
// channel, and its messages reach nobody without an error.
export const SESSION_CHANNEL = 'session';
type SignedOut = { type: 'signed-out' };
function isSignedOut(data: unknown): data is SignedOut {
return typeof data === 'object' && data !== null && 'type' in data && data.type === 'signed-out';
}
// One channel object per tab, for the tab's lifetime. A channel object never
// receives its own messages, so the tab that signs out must update itself.
export function openSessionChannel(onSignedOutElsewhere: () => void) {
const channel = new BroadcastChannel(SESSION_CHANNEL);
channel.onmessage = (event) => {
// A tab still running an older deploy can send a different shape.
if (isSignedOut(event.data)) onSignedOutElsewhere();
};
return {
announceSignOut: () => channel.postMessage({ type: 'signed-out' } satisfies SignedOut),
close: () => channel.close()
};
}
Export the name so no tab can misspell it, and keep one channel object per tab. That
object never receives its own announcement, so the tab that signs out clears itself first.
In React 19.1.0 and Svelte 5.57 mounts, an effect that opened the channel and returned close kept one channel open per tab, React’s Strict Mode included, closed it on unmount, and the
other tab switched to signed out.
Check the shape of what arrives, because a tab left open since an older deploy can post a different message. Then decide about late subscribers. A tab opened after the announcement receives nothing, like a late inbox in the lab, so each tab still asks for the current session when it loads. The message says what just happened; the session check says what is true now.
06 / Already in your toolbox
Recognize the relationship. Check the promises.
These public APIs connect publishers to registered interest, and each makes its own delivery promises.
Redis Pub/Sub: channels without offline replay
Clients use SUBSCRIBE, PUBLISH, and UNSUBSCRIBE to communicate
through channels. Redis documents at-most-once delivery: a disconnected subscriber cannot later
recover missed messages through plain Pub/Sub. Redis Streams adds a different, persisted model.
“Who is interested” and “what survives a disconnect” are separate questions.
NATS: copies, or one member of a group
Ordinary Core NATS subscriptions each receive matching publications. A queue group changes distribution: one member of that group receives a given message, sharing work among members. Our Badge and Activity are independent subscriptions, so Activity reading its copy does not do Badge’s work.
Core NATS publication returns no per-subscriber acceptance report, and its ephemeral delivery offers no offline replay without an added persistence layer.
NATS publish-subscribe ↗NATS queue groups ↗
07 / Make the call
Choose what must reach whom.
Publish / subscribe earns its place when several independent consumers care about the same event and membership can change without changing the producer. It also makes the path harder to trace: finding a publication no longer reveals every reaction. Use clear topic names, documented payloads, and tooling that can show subscriptions and drops.
| The requirement | A useful starting point | Why it fits |
|---|---|---|
| Every interested panel reacts independently | Publish / subscribe | Each subscription gets its own delivery opportunity. Decide what late and slow consumers miss. |
| Save must finish required steps in order | Direct calls or a facade | The caller can receive an outcome for the whole task and handle partial failure. |
| One worker should take each job | A work queue or consumer group | Workers share delivery within the group instead of all doing the same job. |
| A newly opened view needs the latest draft | A state store or query | The current value is available without reconstructing missed notifications. |
| An offline consumer must catch up | A durable queue or event log with suitable retention | Specify recovery, acknowledgment, retry, and ordering. |
Where does Observer fit?
In a typical Observer design, a subject keeps references to observers and notifies them of changes. Topic-based publish / subscribe introduces a routing intermediary: the publisher addresses a topic, and subscriptions express interest separately.
The vocabulary overlaps in libraries, and either arrangement can be synchronous or asynchronous. Timing alone is a poor dividing line. Ask who holds the subscriber list, how interest is selected, and what delivery promises are actually made.
If there is only one stable caller and one required reaction, a direct call may remain the clearest design. Adding a topic would move that dependency out of sight without removing the need for it.
08 / Take the idea with you
Follow the event, then follow each copy.
Explain the design without its name: “Save announces a fact through a named channel. Panels register their own interest, so Save does not manage them. Each panel owns its copy and its subscription’s lifetime.”
From memory, answer three questions about this broker. What does a late subscriber see? What does unsubscribe discard? What does an accepted ID prove? If you answered “an empty inbox,” “its unread entries,” and “a copy was enqueued,” you have the contract as well as the shape.
Now choose one event in your application. Name its producer, its consumers, and a consumer that might be offline. Would missing one message be harmless, recoverable from current state, or unacceptable? Let that answer determine the delivery design.
Connections to follow nextRelated lessons
- Facade gives required workflow steps a coherent entry point.
- Strategy explores making a policy replaceable; admission on a full inbox is one possible policy boundary.
- Observer follows a subject notifying registered observers.
- Mediator owns a coordination rule among participants; our broker routes copies by topic and decides nothing about how consumers respond.