Ever opened two tabs of your React app and watched them slowly drift out of sync? Maybe a logout in one tab doesn’t register in the other. Or a filter change in one view disappears in the second. It's not a bug in your code - it's a blind spot in how most apps handle state.
In Part 1 of this series, I broke down why this happens - and how the browser's native BroadcastChannel
API offers a solid solution for syncing tabs without a backend.
But actually using BroadcastChannel
in a React app? That’s where things get tricky. My early attempts were simple - a quick useEffect
here, an event listener there - but they unraveled fast. Lingering listeners, duplicate actions, messy message state. It quickly became clear: a reliable abstraction needed more thought.
This post walks through the journey of building react-broadcast-sync
, a custom hook designed to make multi-tab communication seamless in React. From message deduplication to smart batching and lifecycle management, here’s everything I had to solve - and how I did it.
Table of Contents
- The Initial Approach: Learning the Limitations
- Redundant Processing: A Distributed System Headache
- Managing Message Lifecycles: Expiration and Cleanup
- Optimizing for Volume: Batching High Frequency Messages
- Tracking Who Sent What: The Source ID
- Focusing on Current State: Keeping Only the Latest Message
- Tuning In: Filtering by Message Type
- Hands On Control: Utility Methods for Messages
- Simplifying Integration: BroadcastProvider
- Making It Bulletproof: Thorough Testing
- Real World Use Cases
- What’s Next
The Initial Approach: Learning the Limitations
Like many devs, my first instinct was to toss BroadcastChannel
into a useEffect
and call it a day.
import { useEffect, useRef } from 'react';
function useInitialBroadcastChannel(channelName: string) {
const channelRef = useRef<BroadcastChannel | null>(null);
useEffect(() => {
const channel = new BroadcastChannel(channelName);
channelRef.current = channel;
channel.onmessage = event => {
console.log('Received:', event.data);
// Problem: If the component unmounts, we’ve got a ghost listener hanging around.
};
return () => {
// Uh-oh: If we forget to close the channel, we risk memory leaks.
channel.close();
};
}, [channelName]);
const postMessage = (data: any) => {
channelRef.current?.postMessage(data);
};
return { postMessage };
}
This worked fine for basic tests - until I tried it in a real app. That’s when the cracks started to show.
Two major issues popped up:
- Leaky listeners: Channels weren’t always cleaned up on unmount.
- Stale state: React callbacks often referenced outdated data.
It became clear that I needed a more React-friendly abstraction - one that understood lifecycles, state, and isolation.
Redundant Processing: A Distributed System Headache
When you're syncing tabs, you're essentially building a distributed system. And one annoying problem pops up fast: double-processing.
Example: Tab A logs the user out and broadcasts it. Tab B receives the message and logs out too. But what if Tab A also reacts to that broadcast? You get a double logout - or worse, an error.
Even though BroadcastChannel
doesn’t echo messages back to the sender, your logic might still be wired to react to all incoming events - including your own.
The Fix: Message IDs and Source IDs
To make smart decisions, each message gets:
- A unique
id
- A
source
ID identifying the tab that sent it
Now, tabs can ignore messages from themselves and skip anything they’ve already seen.
channel.onmessage = event => {
const { id, type, source } = event.data;
if (source === myTabId) return; // ignore my own messages
if (isRecentlyProcessed(id)) return; // skip duplicates
if (type === 'logout') {
logout();
}
};
With this, actions like logout or sync are processed once and only once per tab - even under race conditions.
Managing Message Lifecycles: Expiration and Cleanup
Some messages - like “user is typing…” or “temporary alert” - shouldn’t live forever. But by default, BroadcastChannel
messages aren’t stored at all, so I was persisting them in state. The problem? That state grew forever.
The Fix: Expiration + Cleanup Interval
Each message can now include an expirationDuration
. Behind the scenes, a cleanup timer runs periodically to remove expired messages from state.
postMessage('notification', { text: 'You’re connected!' }, { expirationDuration: 5000 });
The hook checks and removes stale messages every few seconds, keeping your app lean and snappy.
Optimizing for Volume: Batching High Frequency Messages
When syncing typing data or drag events, I noticed major UI jank. Turns out, spamming postMessage()
dozens of times per second is a bad idea.
The Fix: Batching with Optional Bypass
I added a batchingDelayMs
config. Instead of sending each message immediately, the hook groups them within a short window, then sends them as a batch. You can also exclude specific types from batching.
const { postMessage } = useBroadcastChannel('typing', {
batchingDelayMs: 50,
excludedBatchMessageTypes: ['form_submit'],
});
Now, typing updates stay smooth - and critical actions still go through instantly.
Tracking Who Sent What: The Source ID
The source
field turned out to be more than just a way to skip my own messages. It became a powerful way to attribute updates and target logic.
Examples:
- Show which tab sent the latest filter change
- Detect if a message came from a different device
- Ping active tabs and get their IDs
const latest = getLatestMessage({ source: 'tab-abc-123', type: 'theme_change' });
Every message now has traceability - great for debugging or building smarter features.
Focusing on Current State: Keeping Only the Latest Message
Sometimes, history doesn’t matter - only the latest state does.
If your component only cares about the current dashboard filters or online status, why store 50 updates?
The Fix: keepLatestMessage
Mode
This mode tells the hook: "Only keep the newest message of each type." Cleaner state, simpler logic.
const { getLatestMessage } = useBroadcastChannel('filters', {
keepLatestMessage: true,
});
You still get getLatestMessage()
for querying, and you avoid managing long message arrays.
Tuning In: Filtering by Message Type
As the app grew, channels got busier - carrying everything from user presence to theme toggles. But not every component cared about every message.
The Fix: registeredTypes
Specify which message types you want. The hook silently ignores the rest.
const { messages } = useBroadcastChannel('app-state', {
registeredTypes: ['theme_change', 'notification'],
});
Less noise, fewer re-renders, cleaner code.
Hands On Control: Utility Methods for Messages
While automatic cleanup is great, sometimes you need manual control - like clearing a specific notification or resetting state.
Exposed Utilities:
-
getLatestMessage()
- grab the newest message by filter -
clearReceivedMessages()
- remove specific messages -
clearSentMessages({ sync: true })
- delete sent messages and notify others -
clearAllReceivedMessages()
- wipe it clean
clearSentMessages({ types: ['notification'], sync: true });
Powerful tools, available when you need them.
Simplifying Integration: BroadcastProvider
Managing channels manually across components got tedious fast. I was duplicating hook logic and prop-drilling channel methods.
The Fix: React Context
With <BroadcastProvider>
, you define a shared channel once and access it from anywhere.
<BroadcastProvider channelName="global">
<App />
</BroadcastProvider>
Then in any nested component:
const { postMessage, getLatestMessage } = useBroadcastProvider();
Clean, centralized, and idiomatic.
Making It Bulletproof: Thorough Testing
To make this robust, I built a full test suite using a mocked BroadcastChannel
. It simulates tabs, message delivery, cleanup timing, deduplication, batching - the whole thing.
Each feature was tested in isolation and under stress to ensure consistent behavior across tabs and edge cases.
If you're curious, the repo has full test coverage and examples you can learn from or extend.
Real World Use Cases
These features are now powering real multi-tab sync scenarios:
- Counters and input fields synced live across tabs
- Theme changes reflected instantly everywhere
- Logout from one tab propagates to all others
- Filter syncing across dashboards
- Shared draft editing in real-time
It’s fast, simple to integrate, and battle-tested.
What’s Next
This post was all about the engineering behind the hook. In the final post of the series, I’ll walk through:
- Installing
react-broadcast-sync
- Real-world usage patterns
- Demos and gotchas
- Advanced config and recipes
Live demo: react-broadcast-sync.vercel.app
npm: react-broadcast-sync
GitHub: IdanShalem/react-broadcast-sync
Stay tuned - and if you’ve got ideas, questions, or cool use cases, I’d love to hear them!
Top comments (0)