Profile photo of Travis Horn Travis Horn

Syncing Across Tabs with BroadcastChannel

2026-02-04
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.

Two browser windows open to the same URL. A table is visible with pet names
and statuses. Each status is in a text input. The user updates one of the
statuses from "Asleep" to "Awake" and back to "Asleep" again in one of the
windows. Every change to the status is updated in real-time in the other
window.

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>

Two browser windows open to the same URL. A table is visible with pet names
and statuses. Each status is in a text input. The user updates one of the
statuses from "Asleep" to "Awake" and back to "Asleep" again in one of the
windows. Every change to the status is updated in real-time in the other
window.

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.

Here are some more articles you might like: