Syncing Across Tabs with BroadcastChannel
Here’s a scenario: You have a dashboard open in two windows. In the first, you update a record, but when you switch to the second, nothing has changed. It breaks the illusion of a cohesive application. The user must manually refresh the page just to see their own actions reflected.
Historically, we might solve this using clunky workarounds like aggressive polling, complex websocket implementations, or LocalStorage event listeners, which all have their limitations.
Modern browsers, however, provide a native API to solve this: the BroadcastChannel API. It’s a lightweight pub/sub system entirely in the browser. It allows windows, tabs, and workers on the same origin to communicate with each other. Using this API, we can synchronize state instantly without a network round-trip.
The API
The API is pretty simple. Unlike setting up a websocket server or trying to manage shared workers, the BroadcastChannel doesn’t require a handshake or much configuration. You simply start a connection to a named channel. Any other context listening on that channel receives the signal.
The API uses the “structured clone algorithm”, which means we can pass complex JavaScript objects, blobs, maps, and sets without needing to stringify and parse.
Here’s how you might implement it in raw JavaScript:
// Connect to a specific channel
const channel = new BroadcastChannel("myChannel");
// Set up the listener
channel.onmessage = (event) => {
console.log("Received:", event.data);
};
// Broadcast a message
channel.postMessage("Hello, World!");
// Cleanup (when unmounting a component or similar)
channel.close();
In the example above, we’re sending a simple string, but as mentioned, you can pass entire objects just as easily.
Use with Svelte
To keep things manageable, I’ll standardize the messages using an “action” pattern. In Svelte, we wrap the logic inside a custom store.
// store.svelte.js
export class PetStore {
pets = $state([
{ id: 0, name: "Smokey", status: "Asleep" },
{ id: 1, name: "Bandit", status: "Awake" },
]);
constructor() {
this.channel = new BroadcastChannel("petSync");
// Listen for updates from neighboring tabs
this.channel.onmessage = ({ data }) => {
if (data.type === "UPDATE_PET") {
const pet = this.pets.find((p) => p.id === data.payload.id);
if (pet) {
pet.status = data.payload.status;
}
}
};
}
// Action to update local state *and* broadcast to others
updatePetStatus(id, status) {
const pet = this.pets.find((p) => p.id === id);
if (pet) {
pet.status = status; // Updates local UI
// Broadcast to other tabs
this.channel.postMessage({
type: "UPDATE_PET",
payload: $state.snapshot(pet), // Helper to send raw object, not the proxy
});
// In a real application, you'd also fire off this event to the server so
// the change is persisted.
}
}
}
export const petStore = new PetStore();
You can use this store on a Svelte page or component.
<!-- Component.svelte -->
<script>
import { petStore } from '../store.svelte.js';
</script>
<table>
<thead>
<tr>
<th>Name</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each petStore.pets as pet}
<tr>
<td>{pet.name}</td>
<td>
<input
type="text"
value={pet.status}
oninput={(e) => petStore.updatePetStatus(pet.id, e.currentTarget.value)}
/>
</td>
</tr>
{/each}
</tbody>
</table>
When the page is loaded, Svelte builds the table by looping over each pet in the store and adding a row. Each pet’s status is placed in a text input.
When the user updates the text input, the pet store is updated, the store broadcasts a message, and other tabs receive it.
When the other tabs receive a message, they update their store. This keeps everything in sync.

A Generic Helper
Let’s abstract some of this logic. Here’s a helper we can use to enhance any store with BroadcastChannel functionality.
// synced.svelte.js
export class SyncedState {
#channel;
value = $state();
constructor(key, initialValue) {
this.value = initialValue;
this.#channel = new BroadcastChannel(key);
// Handle incoming messages
this.#channel.onmessage = ({ data }) => {
this.value = data;
};
}
// Method to broadcast the current state. Useful if you mutated an
// array/object internally and need to sync.
sync() {
this.#channel.postMessage($state.snapshot(this.value));
}
// Setter that updates and syncs immediately
set(newValue) {
this.value = newValue;
this.sync();
}
}
Now, the store can be refactored to use the helper, and focus itself on just the business logic of updating pet statuses.
// store.svelte.js
import { SyncedState } from "./synced.svelte.js";
class PetStore {
// Initialize the generic synced state wrapped with the helper
#store = new SyncedState("petSync", [
{ id: 0, name: "Smokey", status: "Asleep" },
{ id: 1, name: "Bandit", status: "Awake" },
]);
// Expose the data for components to use
get pets() {
return this.#store.value;
}
updatePetStatus(id, status) {
const pet = this.#store.value.find((p) => p.id === id);
if (pet) {
// Mutate Local State
pet.status = status;
// Trigger the Broadcast
this.#store.sync();
}
}
}
export const petStore = new PetStore();
Using it in a component remains the same. When the user updates the data in one tab, it propagates everywhere else automatically.
<!-- Component.svelte -->
<script>
import { petStore } from '../store.svelte.js';
</script>
<table>
<thead>
<tr>
<th>Name</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each petStore.pets as pet}
<tr>
<td>{pet.name}</td>
<td>
<input
type="text"
value={pet.status}
oninput={(e) => petStore.updatePetStatus(pet.id, e.currentTarget.value)}
/>
</td>
</tr>
{/each}
</tbody>
</table>

Gotchas
While this API has many upsides, there are a few implementation details to keep
in mind. First, remember that BroadcastChannel follows the same-origin policy;
it won’t bridge communication between different subdomains. Also, keep in mind
that a tab cannot hear its own broadcasts. This feature is actually helpful as
it prevents the infinite feedback loops we’d otherwise have to handle manually.
Remember, BroadcastChannel is a pipe, not a bucket. It’s a communication layer, not a storage layer. If a user closes all instances of your app, any state that hasn’t been persisted in some way (LocalStorage, a backend database, etc.) is gone forever.
Low Effort, High Reward
You can implement BroadcastChannel with relatively low effort for a high-reward return. It provides instant UI consistency with zero server overhead. It handles state synchronization locally to avoid stale tabs. It can significantly improve the perceived performance of your app.
Take a look at your current apps. Chances are, there is a stale data bug hiding there that can be easily fixed with BroadcastChannel.
Cover photo by Mirella Callage on Unsplash.
Travis Horn