Keeping Multiple Blazor Tabs in Sync with the Browser Broadcast Channel API
They change something in the first tab.
Nothing happens in the second tab.
This is expected. Each browser tab has its own page, JavaScript context, and Blazor Server circuit. Even though both tabs are displaying the same application for the same user, they don’t automatically share live state.
You could solve this with SignalR, a server-side notification service, or a distributed messaging system. For many applications, however, that would be far more infrastructure than the problem requires.
When the communication only needs to happen between tabs from the same application in the same browser, the browser already provides a lightweight solution: the Broadcast Channel API.
In this article, we will examine:
What developers sometimes mean by the “Broadcast API”
What the Broadcast Channel API actually is
When it is a good fit
Why it is such an interesting browser capability
How to implement it in a Blazor Server application
How to combine it with
localStorageand JavaScript interoperabilityWhere its limitations begin and when to use SignalR instead
What Is the “Broadcast API”?
Developers sometimes informally call this feature the Broadcast API, but the official browser feature is named the Broadcast Channel API.
The API exposes a JavaScript interface named:
BroadcastChannel
A BroadcastChannel allows different browser contexts from the same origin to exchange messages.
Those browser contexts can include:
Browser tabs
Separate browser windows
Same-origin iframes
Web workers
Service workers
You can think of it as a very small, browser-managed publish-and-subscribe system.
A page joins a named channel:
const channel = new BroadcastChannel("application-events");
It can then publish a message:
channel.postMessage({
type: "counter-changed",
value: 5
});
Other browser contexts that have joined the same named channel can receive that message:
channel.onmessage = event => {
console.log(event.data);
};
The browser handles delivering the message to the other listeners.
There is no Web API endpoint to call, no database to poll, and no server-side message broker to configure.
What Is the Broadcast Channel API?
The Broadcast Channel API is a browser API for sending messages between same-origin browsing contexts.
A channel is identified by a string name:
const channel = new BroadcastChannel("counter-demo");
Any other compatible browser context can join that channel by using the same name:
const channel = new BroadcastChannel("counter-demo");
When one context publishes a message, the browser raises a message event for the other channel instances.
Messages are sent with postMessage():
channel.postMessage({
eventType: "counter-updated",
counterValue: 10
});
Messages can contain more than strings. The Broadcast Channel API uses the browser’s structured clone algorithm, which supports many JavaScript data types without requiring the developer to manually convert every message to a JSON string.
When a channel is no longer needed, it should be closed:
channel.close();
Closing the channel disconnects that channel object and allows its resources to be reclaimed.
Same-Origin Communication
The most important Broadcast Channel rule is that participating pages must have the same origin.
An origin consists of the page’s:
Scheme
Host
Port
For example, these two pages have the same origin:
https://example.com/counter
https://example.com/weather
These do not:
https://example.com
https://admin.example.com
Even though both domains belong to the same organization, their hosts are different.
These also have different origins:
http://example.com
https://example.com
The schemes are different.
Different ports also create different origins:
https://localhost:5001
https://localhost:7001
The API intentionally limits communication to same-origin contexts. A tab from an unrelated website cannot simply join your application’s channel and listen to its messages.
A Simple Broadcast Channel Example
Before adding Blazor, consider the browser-only implementation.
Sending a message
const channel = new BroadcastChannel("demo-channel");
channel.postMessage({
type: "status-changed",
status: "Complete",
changedAt: new Date().toISOString()
});
Receiving a message
const channel = new BroadcastChannel("demo-channel");
channel.addEventListener("message", event => {
console.log("Message received:", event.data);
});
Open the application in two tabs. When the first tab sends a message, the second tab receives it.
The API feels almost too simple, which is part of what makes it useful.
Why Is It Pretty Cool?
The Broadcast Channel API solves a real application problem with very little code.
It does not require a server round trip
The message stays within the user’s browser. The first tab does not need to send an event to your application server so that the server can send it back to the second tab.
That makes the communication fast and removes unnecessary server traffic.
It works between otherwise isolated tabs
Browser tabs normally operate independently. The API creates a controlled communication mechanism between them without requiring one tab to have a direct reference to the other.
With window.postMessage(), developers often need a reference to another window, such as a popup or parent frame. A broadcast channel is based on a shared name instead.
It supports structured messages
You are not limited to broadcasting a simple string.
You can send an object that describes the event:
{
type: "preferences-updated",
source: "settings-page",
payload: {
theme: "dark",
pageSize: 50
}
}
This makes it possible to create a small event contract for your application.
It separates the sender from the receiver
The sending page does not need to know which tabs are open.
It publishes an event to a named channel. Interested tabs listen to that channel and decide what to do with the message.
That is a simple form of decoupling.
It is a browser capability, not a framework feature
The Broadcast Channel API is not specific to Blazor, React, Angular, or Vue.
The same technique can be used across different front-end technologies, as long as the pages run under the same origin.
The Blazor Server Challenge
In Blazor Server, application logic executes on the server. Browser events and browser APIs, however, execute in the browser.
That creates a boundary:
Blazor component
|
| JavaScript interoperability
v
Browser JavaScript
|
v
BroadcastChannel API
Blazor cannot directly instantiate a browser BroadcastChannel from C#.
Instead:
A Blazor component calls JavaScript.
JavaScript creates or accesses the channel.
JavaScript listens for browser messages.
When a message arrives, JavaScript calls a .NET method.
The Blazor component updates its state and re-renders.
Blazor supports calling JavaScript from .NET through IJSRuntime. It also supports calling .NET methods from JavaScript through mechanisms such as [JSInvokable] and a DotNetObjectReference.
The Demo Scenario
The demonstration uses two familiar Blazor pages:
The Counter page sends updates.
The Weather page receives updates.
The flow is:
A user increments the counter.
The Counter component updates its scoped state service.
The new value is written to
localStorage.The new value is broadcast through a named channel.
Another tab receives the broadcast.
JavaScript calls a method on the Weather component.
The Weather component updates its displayed value.
A newly opened tab restores the latest value from
localStorage.
The implementation combines three types of state communication:
Scoped service
In-tab Blazor state
BroadcastChannel
Live cross-tab notification
localStorage
Latest-value restoration
Each piece solves a different part of the problem.
Implementing the Demo
Step 1: Create the Shared Counter State Service
Create the following file:
Components/Services/CounterStateService.cs
namespace BroadcastChannelDemo.Components.Services;
public sealed class CounterStateService
{
public event Action? CounterChanged;
public int CurrentCount { get; private set; }
public void Increment()
{
CurrentCount++;
CounterChanged?.Invoke();
}
public void SetCount(int value)
{
if (CurrentCount == value)
{
return;
}
CurrentCount = value;
CounterChanged?.Invoke();
}
}
The service stores the counter value and raises an event whenever that value changes.
In a Blazor Server application, a scoped service normally lives for the lifetime of a Blazor circuit. Each independently opened browser tab usually establishes its own circuit, so the scoped service is useful for navigation and component communication within one tab, but it does not automatically synchronize multiple tabs.
That is why the Broadcast Channel API is still needed.
Step 2: Register the Service
Register the state service in Program.cs:
using BroadcastChannelDemo.Components;
using BroadcastChannelDemo.Components.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddScoped<CounterStateService>();
var app = builder.Build();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
The important line is:
builder.Services.AddScoped<CounterStateService>();
This makes one state service available throughout a user’s Blazor circuit.
Step 3: Create the JavaScript Module
Create:
wwwroot/js/broadcast-channel-demo.js
const activeChannels = new Map();
/**
* Returns an existing channel or creates a new one.
*/
function getOrCreateChannel(channelName) {
let channel = activeChannels.get(channelName);
if (!channel) {
channel = new BroadcastChannel(channelName);
activeChannels.set(channelName, channel);
}
return channel;
}
/**
* Broadcasts a message to other browser contexts that have
* joined the same named channel.
*/
export function broadcastMessage(channelName, message) {
const channel = getOrCreateChannel(channelName);
channel.postMessage(message);
}
/**
* Initializes a channel listener and forwards received messages
* to an instance method on a Blazor component.
*/
export function initializeBroadcastChannel(channelName, dotNetHelper) {
const channel = getOrCreateChannel(channelName);
channel.onmessage = async event => {
try {
await dotNetHelper.invokeMethodAsync(
"HandleBroadcastMessage",
event.data
);
} catch (error) {
console.error(
`Unable to forward a message from '${channelName}' to .NET.`,
error
);
}
};
}
/**
* Writes a value to localStorage.
*/
export function writeStorageValue(storageKey, value) {
localStorage.setItem(storageKey, value);
}
/**
* Reads a value from localStorage.
*/
export function readStorageValue(storageKey) {
return localStorage.getItem(storageKey);
}
/**
* Closes and removes a specific channel.
*/
export function disposeBroadcastChannel(channelName) {
const channel = activeChannels.get(channelName);
if (!channel) {
return;
}
channel.close();
activeChannels.delete(channelName);
}
This module provides five responsibilities:
Creating or retrieving a named channel
Sending messages
Receiving messages
Reading and writing browser storage
Closing channels during component cleanup
Using a JavaScript module keeps the browser-specific implementation separate from the Razor components.
Step 4: Define a Message Contract
For a very small demonstration, you could send only an integer:
channel.postMessage(5);
A typed message is more maintainable, especially as the application grows.
Create:
Components/Models/CounterBroadcastMessage.cs
namespace BroadcastChannelDemo.Components.Models;
public sealed record CounterBroadcastMessage(
string Type,
int Value,
DateTimeOffset ChangedAt);
The equivalent JavaScript object will look like this:
{
type: "counter-updated",
value: 5,
changedAt: "2026-07-04T18:30:00.000Z"
}
The Type property allows the channel to support additional events later.
For example:
counter-updated
user-preferences-updated
record-saved
session-ending
cache-invalidated
A channel name identifies the general message stream. The message type identifies the event within that stream.
Step 5: Update the Counter Page
The Counter page acts as the sender.
@page "/counter"
@rendermode InteractiveServer
@implements IAsyncDisposable
@using BroadcastChannelDemo.Components.Models
@using BroadcastChannelDemo.Components.Services
@using Microsoft.JSInterop
@inject CounterStateService CounterState
@inject IJSRuntime JS
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">
Current count: <strong>@CounterState.CurrentCount</strong>
</p>
<button class="btn btn-primary" @onclick="IncrementCount">
Increment
</button>
@code {
private const string ChannelName = "blazor-counter-demo";
private const string StorageKey = "blazor-counter-value";
private IJSObjectReference? module;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
module = await JS.InvokeAsync<IJSObjectReference>(
"import",
"./js/broadcast-channel-demo.js");
}
private async Task IncrementCount()
{
CounterState.Increment();
if (module is null)
{
return;
}
var currentValue = CounterState.CurrentCount;
await module.InvokeVoidAsync(
"writeStorageValue",
StorageKey,
currentValue.ToString());
var message = new CounterBroadcastMessage(
Type: "counter-updated",
Value: currentValue,
ChangedAt: DateTimeOffset.UtcNow);
await module.InvokeVoidAsync(
"broadcastMessage",
ChannelName,
message);
}
public async ValueTask DisposeAsync()
{
if (module is null)
{
return;
}
try
{
await module.DisposeAsync();
}
catch (JSDisconnectedException)
{
// The Blazor circuit was already disconnected.
}
}
}
When the button is selected, the component:
Updates the scoped service.
Writes the latest count to
localStorage.Creates a message containing the event type, value, and timestamp.
Broadcasts that message to the other tabs.
Why Store the Value as Well as Broadcast It?
Broadcast channels deliver events. They are not a database and do not maintain a message history.
Consider this sequence:
Tab A increments the counter to five.
Tab A broadcasts the value.
No other tab is open.
The user opens Tab B.
Tab B joins the channel.
Tab B does not receive the earlier message because it was not listening when the message was sent.
That is why the demo also uses localStorage.
The broadcast answers:
What changed just now?
The stored value answers:
What is the most recently known value?
This is a useful distinction between an event and state.
Step 6: Update the Weather Page
The Weather page acts as the receiver.
@page "/weather"
@rendermode InteractiveServer
@implements IAsyncDisposable
@using BroadcastChannelDemo.Components.Models
@using Microsoft.JSInterop
@inject IJSRuntime JS
<PageTitle>Weather</PageTitle>
<h1>Weather</h1>
<div class="alert alert-info" role="status">
<p>
Latest counter value:
<strong>@currentCounterValue</strong>
</p>
@if (!string.IsNullOrWhiteSpace(lastBroadcastMessage))
{
<p class="mb-0">@lastBroadcastMessage</p>
}
</div>
@code {
private const string ChannelName = "blazor-counter-demo";
private const string StorageKey = "blazor-counter-value";
private IJSObjectReference? module;
private DotNetObjectReference<Weather>? dotNetReference;
private int currentCounterValue;
private string? lastBroadcastMessage;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
{
return;
}
module = await JS.InvokeAsync<IJSObjectReference>(
"import",
"./js/broadcast-channel-demo.js");
dotNetReference = DotNetObjectReference.Create(this);
await module.InvokeVoidAsync(
"initializeBroadcastChannel",
ChannelName,
dotNetReference);
var storedValue = await module.InvokeAsync<string?>(
"readStorageValue",
StorageKey);
if (int.TryParse(storedValue, out var parsedValue))
{
currentCounterValue = parsedValue;
lastBroadcastMessage =
"Restored the latest value from browser storage.";
await InvokeAsync(StateHasChanged);
}
}
[JSInvokable]
public async Task HandleBroadcastMessage(
CounterBroadcastMessage message)
{
if (!string.Equals(
message.Type,
"counter-updated",
StringComparison.OrdinalIgnoreCase))
{
return;
}
currentCounterValue = message.Value;
lastBroadcastMessage =
$"Received an update at {message.ChangedAt.LocalDateTime:T}.";
await InvokeAsync(StateHasChanged);
}
public async ValueTask DisposeAsync()
{
dotNetReference?.Dispose();
if (module is null)
{
return;
}
try
{
await module.InvokeVoidAsync(
"disposeBroadcastChannel",
ChannelName);
await module.DisposeAsync();
}
catch (JSDisconnectedException)
{
// The browser or Blazor circuit was already disconnected.
}
}
}
Understanding the Receiver Lifecycle
The receiver performs its browser initialization in OnAfterRenderAsync.
That is important because browser APIs and JavaScript interoperability should not be invoked before the component has reached a browser-connected rendering phase.
On the first interactive render, the component:
Imports the JavaScript module.
Creates a
DotNetObjectReference.Passes that reference to JavaScript.
Joins the named broadcast channel.
Registers a JavaScript message handler.
Reads the most recent value from
localStorage.Updates the UI.
When a broadcast is received, JavaScript executes:
await dotNetHelper.invokeMethodAsync(
"HandleBroadcastMessage",
event.data
);
Blazor finds the corresponding method because it is marked with:
[JSInvokable]
The component updates its C# fields and requests a render through:
await InvokeAsync(StateHasChanged);
Using InvokeAsync ensures that the update is dispatched through the component’s synchronization context instead of assuming that the JavaScript callback is already executing in the correct rendering context.
Step 7: Test the Application
Run the Blazor application and perform the following test.
Tab A
Open:
/counter
Tab B
Open:
/weather
Keep both tabs visible, or switch between them.
Click Increment in Tab A.
Tab B should display the new counter value without requiring:
A page refresh
A database query
A controller call
A custom SignalR hub
Server-side polling
Next, close Tab B.
Increment the counter several more times in Tab A.
Open the Weather page in a new tab. It should immediately restore the latest value from localStorage, even though the new tab did not receive the earlier broadcasts.
How the Complete Data Flow Works
Counter increment flow
User selects Increment
|
v
CounterStateService.Increment()
|
v
Counter component reads the new value
|
+-----------------------------+
| |
v v
Write to localStorage Broadcast the update
|
v
Other same-origin tabs
Weather update flow
BroadcastChannel receives message
|
v
JavaScript message handler
|
v
dotNetHelper.invokeMethodAsync(...)
|
v
[JSInvokable] C# method
|
v
Update component state
|
v
StateHasChanged()
|
v
Updated Blazor UI
New-tab restoration flow
Weather page opens
|
v
Import JavaScript module
|
v
Join BroadcastChannel
|
v
Read localStorage
|
v
Display latest known value
When Should You Use the Broadcast Channel API?
The API works well when the communication is:
Between tabs or windows on the same browser
Between pages on the same origin
Local to a single user’s browser profile
Event-oriented
Relatively lightweight
Not dependent on guaranteed message delivery
Good use cases include the following.
Synchronizing user preferences
A user changes the application theme or display density in one tab. Other open tabs update immediately.
{
type: "theme-changed",
theme: "dark"
}
Notifying tabs that authentication changed
One tab signs out. Other tabs receive a notification and can navigate to the sign-in page or clear sensitive state.
Do not broadcast authentication tokens or secrets. Broadcast only the event that the authentication state changed.
{
type: "user-signed-out"
}
Invalidating cached data
A user updates a record in one tab. Other tabs are told that their local view may be stale.
{
type: "customer-updated",
customerId: 451
}
The receiving tab can then decide whether to reload that record.
Coordinating dashboards
One page changes a filter, time range, or selected project. Another dashboard tab can react to the selection.
Preventing duplicate local work
Tabs can announce that one tab has started a browser-local operation, such as editing a document or refreshing an expensive client-side cache.
Coordinating multi-page workflows
A user may have a list page open in one tab and a detail page open in another. Saving the detail record can notify the list page to refresh.
When Should You Not Use It?
The Broadcast Channel API is useful, but it is not a replacement for every messaging technology.
Do not use it for communication between different users
Broadcast messages remain within compatible contexts in one browser environment.
They do not synchronize:
Two different users
Two different computers
A phone and a desktop
Different browser profiles
Different browser applications
Use a server-side solution such as SignalR when events must be delivered across devices or users.
Do not use it as permanent storage
A channel does not retain a durable history.
Use:
localStoragesessionStorageIndexedDB
An application database
A server-side cache
depending on the durability and scope you need.
Do not assume guaranteed delivery
A tab may be:
Closed
Suspended
Crashed
Not yet listening
Running under a different origin
Treat broadcasts as notifications, not as an authoritative transaction log.
Do not send secrets
Avoid broadcasting:
Access tokens
Refresh tokens
Passwords
Private keys
Sensitive personal data
Full authorization context
Every same-origin context able to join that channel may receive the message. Send the minimum information needed to describe the event.
Do not use it for large data transfers
Broadcast small event messages, identifiers, status changes, and cache-invalidation notices.
For large datasets, broadcast an event telling the receiving tab what changed, and let that tab obtain the authoritative data through the appropriate application service.
Broadcast Channel or SignalR?
A simple decision table helps clarify the difference.
| Requirement | Broadcast Channel | SignalR |
|---|---|---|
| Communicate between tabs in one browser | Yes | Yes |
| Communicate between different users | No | Yes |
| Communicate between devices | No | Yes |
| Requires server infrastructure | No additional infrastructure | Yes |
| Works across unrelated origins | No | Potentially |
| Supports server-originated events | No | Yes |
| Durable delivery | No | Not automatically |
| Best for local tab coordination | Yes | Usually excessive |
| Best for multi-user real-time applications | No | Yes |
Use Broadcast Channel when the browser itself is the communication boundary.
Use SignalR when the application server is the communication hub.
In some applications, using both is reasonable:
Server event
|
v
SignalR sends event to one active tab
|
v
That tab broadcasts a local notification
|
v
Other tabs update
However, this should be an intentional design decision. If every tab already has its own SignalR connection and must receive every server event independently, an additional broadcast layer may not be necessary.
Production Improvements
The demo is intentionally small. A production implementation should add a few safeguards.
Use versioned messages
Message contracts change over time. Add a version:
{
version: 1,
type: "counter-updated",
payload: {
value: 5
}
}
The receiver can reject unsupported versions instead of misinterpreting the message.
Add a message identifier
{
messageId: "be88a587-3455-46a2-9e9f-b1562bb52a65",
type: "counter-updated",
payload: {
value: 5
}
}
A receiver can use this identifier to avoid processing accidental duplicates.
Include a sender identifier
Each tab can generate a unique identifier:
const tabId = crypto.randomUUID();
Include it in every message:
{
senderId: tabId,
type: "counter-updated",
payload: {
value: 5
}
}
This helps with diagnostics and allows a tab to ignore its own messages when needed.
Validate every incoming message
Do not assume that every received object has the expected structure.
function isCounterMessage(message) {
return message !== null
&& typeof message === "object"
&& message.type === "counter-updated"
&& Number.isInteger(message.value);
}
Validation is especially important when several application features share a channel.
Avoid one global channel for everything
Instead of:
application
consider focused channel names:
application-auth
application-preferences
application-record-events
Focused channels reduce unrelated message handling and make the intent clearer.
Handle unsupported browsers
Feature detection is straightforward:
export function isBroadcastChannelSupported() {
return "BroadcastChannel" in globalThis;
}
A production application can fall back to another mechanism when necessary.
Clean up JavaScript and .NET references
A DotNetObjectReference keeps a callable connection to the .NET object. Dispose it when the component is removed.
Also close the broadcast channel and release imported JavaScript modules.
This becomes especially important in long-running Blazor Server applications, where users may navigate among many components during one circuit.
Treat browser state as advisory
For business-critical information, the server or database should remain authoritative.
A broadcast can say:
Customer 451 changed.
The receiving tab should generally reload Customer 451 from the authoritative application service instead of trusting a complete customer object contained in the browser message.
A Reusable Event-Bus Design
Once the basic demonstration works, the JavaScript can be wrapped behind a reusable C# service.
For example:
public interface IBrowserBroadcastService
{
ValueTask PublishAsync<TMessage>(
string channelName,
TMessage message);
ValueTask<string?> ReadStorageAsync(string key);
ValueTask WriteStorageAsync(
string key,
string value);
}
A component could then publish an event without knowing the JavaScript function names:
await BroadcastService.PublishAsync(
"application-record-events",
new RecordChangedMessage(
RecordType: "Customer",
RecordId: customerId,
ChangeType: "Updated"));
This approach provides:
One location for channel naming rules
Centralized error handling
Consistent message envelopes
Easier unit testing of components
Less JavaScript interop code in Razor files
A natural place to add logging and browser feature detection
The Broadcast Channel API remains a browser technology, but it does not have to leak into every component.
The Most Important Design Lesson
The interesting part of this example is not the counter itself.
The important lesson is that different state mechanisms solve different state problems.
The scoped Blazor service provides:
State within one Blazor circuit
The Broadcast Channel API provides:
Live notifications among same-origin tabs
localStorage provides:
A latest-known browser value for tabs that open later
None of these mechanisms replaces the others.
Together, they create a lightweight cross-tab synchronization pattern without requiring a backend messaging system.
Conclusion
The Broadcast Channel API is one of those browser features that many developers never encounter until they need it.
Then it feels almost perfect for the problem.
With only a named channel, postMessage(), and a message listener, your application can coordinate multiple same-origin tabs without adding another server-side communication layer.
In a Blazor Server application, JavaScript interoperability bridges the browser API and your Razor components:
Blazor component
↕
JavaScript interoperability
↕
BroadcastChannel
↕
Other browser tabs
Combining that live event stream with localStorage gives you both immediate updates and restore-on-open behavior.
It is not a replacement for SignalR, durable storage, or a server-side event bus. It is a focused tool for a focused problem: allowing related pages in the same browser to communicate.
For tab-to-tab coordination, cache invalidation, preference synchronization, sign-out notifications, and multi-page workflows, that is not merely convenient.
It is pretty cool.

Comments
Post a Comment