In this tutorial we will learn about preventing infinite echo loops in react broadcast channel. Imagine you open an online store in two tabs on your laptop. In Tab 1, you click “Add to Cart”. You expect Tab 2 to instantly update the cart count from 0 to 1 without refreshing the page.
To make tabs talk to each other in modern browsers, developers use the native BroadcastChannel API. It works like a walkie-talkie between all open tabs on the same website. When Tab 1 speaks into the channel, Tab 2 hears the message and updates its screen.
This sounds straightforward. But a dangerous bug frequently happens: the echo loop.
Here is how it unfolds:
- You click “Add to Cart” in Tab 1.
- Tab 1 shouts: “Cart count is now 1!”
- Tab 2 hears the message and updates its local cart state to
1. - In React, updating state usually triggers a re-render and fires side effects.
- If Tab 2’s side effect sees the cart count change to
1, it shouts into the walkie-talkie: “Cart count is now 1!” - Tab 1 hears Tab 2’s message, sets its state again, and shouts back: “Cart count is now 1!”
Within milliseconds, the two tabs shout back and forth thousands of times. The browser’s CPU spikes to 100%, the fans spin up, and both tabs freeze.
Why People Use Common Workarounds
When developers encounter this loop, they often reach for quick band-aids. Let us look at why those temporary fixes fall short.
Workaround 1: Falling Back to the storage Event
Before BroadcastChannel, developers used window.addEventListener('storage', ...). By writing data into localStorage, other tabs receive a storage event.
Why it hurts:
While localStorage only notifies other tabs (not the tab that wrote the key), it has major drawbacks. It blocks the browser’s main thread during synchronous disk writes, has a strict 5MB quota, and still creates echo loops if multiple tabs write back into the same key during updates.
Workaround 2: Debouncing State Updates
Some developers wrap their broadcast function in a 300ms debounce or throttle.
Why it hurts:
Debouncing merely slows the loop down; it does not eliminate it. Even worse, it introduces lag into the user interface and drops rapid legitimate user actions, like clicking an upvote button three times quickly.
Workaround 3: Checking if (prev === next)
Checking whether the incoming value equals the current value works for simple numbers, but it breaks for complex data. If your state is an array of cart items or a user session object, JavaScript object comparisons ({} !== {}) fail, allowing the loop to continue unchecked.
The most common trap is attaching a broadcast call directly to a state change inside useEffect:
// The classic infinite loop trap
const [cartCount, setCartCount] = useState(0);
useEffect(() => {
const channel = new BroadcastChannel('cart_sync');
channel.onmessage = (event) => {
setCartCount(event.data);
};
return () => channel.close();
}, []);
// DANGEROUS: This effect runs on EVERY state change, including syncs!
useEffect(() => {
const channel = new BroadcastChannel('cart_sync');
channel.postMessage(cartCount);
return () => channel.close();
}, [cartCount]);
Why does this fail?
Because the second useEffect cannot tell the difference between:
- Action A: The user actually clicked a button in this tab.
- Action B: The state changed because another tab told it to change.
Whenever setCartCount runs from an incoming message, the second effect immediately fires, sending the value right back into the channel.
The Solution
To permanently break the echo loop, we apply two simple rules:
- Every tab gets a unique identity (
tabId):When a tab opens, it generates a unique random ID. Whenever it sends a message, it tags the message with itstabId. If a tab ever hears a message sent by itself, it drops it immediately. - Never re-broadcast what you receive:We separate user actions from sync listeners. Only user clicks are allowed to broadcast outward. When a tab receives an update from another tab, it silently updates its local state without sending a message back.
Think of it like a group conversation: if someone asks a question and you write down the answer, you do not immediately repeat the answer out loud to the room. You just update your notes.
Building the Solution Step-by-Step:
import { useEffect, useRef, useState, useCallback } from 'react';
interface SyncMessage<T> {
senderId: string; // Identifies which browser tab sent the message.
payload: T; // Contains the state value to share with other tabs.
}
export function useBroadcastSync<T>(channelName: string, initialValue: T) {
// Stores the current state. Each tab initially starts with initialValue.
const [state, setState] = useState<T>(initialValue);
// Stores the BroadcastChannel instance without causing re-renders.
const channelRef = useRef<BroadcastChannel | null>(null);
// Stores this tab's unique ID so we can identify our own messages.
const tabIdRef = useRef<string>('');
useEffect(() => {
// Generate an ID for this tab to distinguish its messages from other tabs.
tabIdRef.current = Math.random().toString(36).substring(2, 9);
// Create a communication channel. Tabs using the same channelName
// can send messages to each other on the same origin.
const channel = new BroadcastChannel(channelName);
channelRef.current = channel;
// Listen for messages broadcast by other tabs.
channel.onmessage = (event: MessageEvent<SyncMessage<T>>) => {
const { senderId, payload } = event.data;
// Ignore messages sent by this tab.
if (senderId === tabIdRef.current) return;
// Update this tab's state with the value received from another tab.
setState(payload);
};
// Cleanup when the component unmounts or channelName changes.
return () => {
channel.close(); // Stop communication through this channel.
channelRef.current = null; // Remove the stored channel reference.
};
}, [channelName]); // Recreate the channel only when channelName changes.
const updateAndBroadcast = useCallback(
(nextState: T | ((prev: T) => T)) => {
// Use React's functional state update to get the latest current value.
setState((current) => {
// Support both direct values (e.g. 10) and updater functions
// (e.g. prev => prev + 1), just like React's setState.
const resolved =
typeof nextState === 'function'
? (nextState as (prev: T) => T)(current)
: nextState;
// Broadcast the resolved value so other tabs can synchronize.
channelRef.current?.postMessage({
senderId: tabIdRef.current, // Identify the sending tab.
payload: resolved, // Send the updated state.
});
// Update the current tab's state with the resolved value.
return resolved;
});
},
[] // The callback stays stable across renders.
);
// Return the current state and the function for updating and broadcasting it.
return [state, updateAndBroadcast] as const;
}
How this hook works:
tabIdRef: Generates a random identifier when the component mounts. This identifier stays constant for the entire life of the tab.senderId === tabIdRef.current: The listener checks if the message came from this exact tab. If it did, it drops it immediately.updateAndBroadcast: This function is the only place wherechannel.postMessageis called. It only runs when the user explicitly triggers an action in this tab.- Clean Receiving: When
channel.onmessagereceives data from another tab, it callssetState(payload)directly. It does NOT callupdateAndBroadcast. This breaks the cycle completely.
Using the Hook in a Real Component
import React from 'react';
import { useBroadcastSync } from './useBroadcastSync';
export function CartWidget() {
const [cartCount, setCartCount] = useBroadcastSync<number>('shopping_cart', 0);
const handleAddItem = () => {
setCartCount((prev) => prev + 1);
};
const handleClearCart = () => {
setCartCount(0);
};
return (
<div className="cart-widget">
<span>Items in Cart: {cartCount}</span>
<button type="button" onClick={handleAddItem}>
Add Item
</button>
<button type="button" onClick={handleClearCart}>
Clear
</button>
</div>
);
}
When you open this component in two tabs:
- Click Add Item in Tab 1: Tab 1 updates to
1and broadcasts to Tab 2. - Tab 2 receives the message, silently sets its counter to
1, and does not broadcast back. - Both tabs stay in sync with zero latency and zero risk of freezing.
Conclusion & Key Takeaways
The BroadcastChannel API is one of the fastest and cleanest ways to synchronize state across multiple tabs. However, pairing it with React requires discipline around event sources.
Key takeaways:
- Never trigger broadcasts from passive
useEffecthooks listening to state changes. - Give every browser tab a unique identifier to filter out self-messages.
- Only broadcast outward during explicit user events.
- Update local state silently when receiving background messages.
Frequently Asked Questions
Does BroadcastChannel work across different browser windows?
Yes. BroadcastChannel communicates across tabs, windows, and iframes, as long as they share the exact same origin (protocol, domain, and port).
What happens in private/incognito browsing windows?
In modern browsers, private tabs share the same private session and can communicate with other private tabs on the same origin. However, private tabs cannot communicate with regular browsing tabs.
What if a browser does not support BroadcastChannel?
BroadcastChannel is supported in all modern browsers (Chrome, Edge, Firefox, Safari). For very old legacy browsers, you can provide a fallback using the storage event or a polyfill.
