How to Duplicate a Browser Tab and Maintain App State in Angular
Use a Broadcast Channel to maintain login, session data and app state when opening new browser tabs.

Have you ever developed an Angular app and used the web browser functionality to duplicate your active tab, only to be met with your app’s login screen? That’s so frustrating, right? But take heart! I’m here to tell you that all is not lost. There is a way we can maintain our state and/or session data upon opening a new tab and it all starts with Broadcast Channel!
Broadcast Channel
The overview of Broadcast Channel is that it allows you to send data between different browser tabs or nodejs processes.
With this ability, we can communicate between browser tabs to keep our app’s state, data, and user login status in sync between all browser tabs.
To start, install Broadcast Channel with npm install broadcast-channel
Here’s the Angular service that I use for the Broadcast Channel (broadcast-channel.service.ts):
import { Injectable, OnDestroy } from '@angular/core';
import { BroadcastMessageType } from 'app/shared/enums/broadcast-messages.enum';
import { BroadcastMessage } from 'app/shared/models/messages/message.model';
import { SessionLogoutService } from 'app/shared/services/session-logout.service';
import { SessionService } from 'app/shared/services/session.service';
import { TokenService } from 'app/shared/services/token.service';
import { BroadcastChannel } from 'broadcast-channel';
import { Subject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class BroadcastChannelService implements OnDestroy {
private channel: BroadcastChannel<BroadcastMessage>;
public constructor(
private sessionLogoutService: SessionLogoutService,
private sessionService: SessionService,
private tokenService: TokenService
) {}
public ngOnDestroy(): void {
this.channel.close();
this.refreshSignalRConnections$?.next();
this.refreshSignalRConnections$?.complete();
}
public postMessage(message: BroadcastMessage): void {
if (this.channel !== undefined) {
this.channel.postMessage(message);
}
}
// This is called in the ngOnInit method of your app's root component
public init(): void {
// Pick any random string for this for the channel's name
this.channel = new BroadcastChannel('my-app-broadcast-channel');
this.channel.onmessage = this.handleMessage.bind(this);
}
/* IMPORTANT: Sender will NOT receive their own message. */
private handleMessage(message: BroadcastMessage): void {
switch (message.type) {
case BroadcastMessageType.IsAnotherTabLoggedIn:
if (this.tokenService.isLoggedIn()) {
this.postMessage({
data: this.sessionService.getLoginSessionData(),
type: BroadcastMessageType.Login
});
}
break;
case BroadcastMessageType.Login:
if (!this.tokenService.isLoggedIn()) {
this.sessionService.loginViaSession(message.data);
}
break;
case BroadcastMessageType.Logout:
if (this.tokenService.isLoggedIn()) {
this.sessionLogoutService.logoutViaSession();
}
break;
default:
break;
}
}
}And some supporting files:
a) broadcast-messages.enum.ts
export enum BroadcastMessageType {
IsAnotherTabLoggedIn,
Login,
Logout
}b) message.model.ts
import { BroadcastMessageType } from 'app/shared/enums/broadcast-messages.enum';
export interface BroadcastMessage {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data?: any; // Must be JSON.stringify'able
type: BroadcastMessageType;
}In the BroadcastChannelService, it’s the postMessage and handleMessagemethods that provide the core of how inter-tab communication works. It’s important to note that the sending browser tab will not receive its own message.
Not shown here is the TokenService which is mainly used to store the user’s JWT access token, refresh token, and other methods such as checking if the user is logged into the app.
Later, we will look at the methods in the SessionService.
Duplicating a Tab
Let’s now take a dive into how this all works when we duplicate a browser tab. When the new tab starts, it will attempt to enter your app at the same URL as the existing tab. Your app’s routing rules will determine how that works. But before that happens, your app’s main entry point will run. This is typically in your root component, commonly known as app.component.ts, and is tied to whatever component is put into the body of your index.html file:
<body>
<app-root></app-root>
</body>Inside of your root component’s ngOnInit function is where we will call the init method of the BroadcastChannelService.
import { BroadcastChannelService } from 'app/shared/services/broadcast-channel.service';
public ngOnInit(): void {
this.broadcastChannelService.init();
}During this initialization, the BroadcastChannelService creates the channel and begins listening to broadcast messages. Because this happens in both browser tabs, both have now created a channel with the same name which allows them to send messages to each other!
Next, in the root component’s ngAfterViewInit method, we begin the process to check if another browser tab is already logged in to your app. This is so that we can send over any state/data we need from the existing logged-in browser tab. Here are some of the related methods from app.component.ts:
import { BroadcastMessageType } from 'app/shared/enums/broadcast-messages.enum';
import { BroadcastChannelService } from 'app/shared/services/broadcast-channel.service';
import { RouterService } from 'app/shared/services/router.service';
import { SessionUtilityService } from 'app/shared/services/session-utility.service';
import { TokenService } from 'app/shared/services/token.service';
@Component({
selector: 'app-root',
styleUrls: ['app.component.scss'],
templateUrl: 'app.component.html'
})
export class AppComponent implements AfterViewInit, OnDestroy, OnInit {
public constructor(
private broadcastChannelService: BroadcastChannelService,
private routerService: RouterService,
private sessionUtilityService: SessionUtilityService,
private tokenService: TokenService
) {}
public ngOnInit(): void {
this.broadcastChannelService.init();
}
public ngAfterViewInit(): void {
this.checkIfAnotherTabIsLoggedIn();
}
public ngOnDestroy(): void {
this.broadcastChannelService?.destroy();
}
private async checkIfAnotherTabIsLoggedIn(): Promise<void> {
if (!this.tokenService.isLoggedIn()) {
this.sessionUtilityService.isSessionLogin = true;
this.broadcastChannelService
.postMessage({ type: BroadcastMessageType.IsAnotherTabLoggedIn });
} else {
this.tokenService.parseAndSaveTokenDetails();
}
}
}In the method checkIfAnotherTabIsLoggedIn, we first check if the user is logged in. In this example app, for the first browser tab opened, we’re assuming that by the time the root component is created, the user is already logged in. This could happen via an outside process, such as Okta, which redirects a user back to your app after they have logged in.
If the user is logged in, we parse their JWT access token and store any relevant data needed about the user. Then we proceed as normal.
However, if the user is not logged in, we store (in the SessionUtilityService) that the user is logging in via session (aka tab) duplication.
Here is the code for the simple SessionUtilityService which contains only getters & setters. We use a service for this as the getter will be used in multiple places as we’ll see later on.
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot } from '@angular/router';
@Injectable({ providedIn: 'root' })
export class SessionUtilityService {
private _isSessionLogin: boolean = false;
private _originalRoute: ActivatedRouteSnapshot;
public get isSessionLogin(): boolean {
return this._isSessionLogin;
}
public set isSessionLogin(value: boolean) {
this._isSessionLogin = value;
}
public get originalRoute(): ActivatedRouteSnapshot {
return this._originalRoute;
}
public set originalRoute(value: ActivatedRouteSnapshot) {
this._originalRoute = value;
}Asking if any other Browser Tab is Logged In
After we store the fact that we’re logging in via tab duplication, we send out a message over the broadcast channel to see if any other browser tabs of our app have a logged-in user:
this.broadcastChannelService
.postMessage({ type: BroadcastMessageType.IsAnotherTabLoggedIn });This will cause all other browser tabs to receive this message and will cause the following code of the BroadcastChannelService to be executed:
case BroadcastMessageType.IsAnotherTabLoggedIn:
if (this.tokenService.isLoggedIn()) {
this.postMessage({
data: this.sessionService.getLoginSessionData(),
type: BroadcastMessageType.Login
});
}
break;What we are doing here is:
1) Checking if the user is logged in, and if so:
2) Posting a “Login” broadcast message (in our broadcast channel) that contains any data we need the new tab to have so that it can have the full state that it needs. This data would include anything needed that you are storing in sessionStorage (since such data is not duplicated across browser tabs); any data that you may be storing in memory (eg. app state data in any services); the user’s access & refresh tokens (which indicate a valid login); and more! Feel free to send anything you want, even objects. The only limitation is that the data must be able to be JSON.stringify-able.
Log-in the User in the New Browser Tab
Now that we have broadcast our login data from a logged-in browser tab, the following code in BroadcastChannelService will handle the reception of the “Login” message:
case BroadcastMessageType.Login:
if (!this.tokenService.isLoggedIn()) {
this.sessionService.loginViaSession(message.data);
}
break;In this, we first check if we are not logged in. This should only be true for the new browser tab. Next, we take all that state/session/memory/token data and wire up the new instance of the app (in the new browser tab) with it. Here is some example code in the SessionService:
import { Injectable } from '@angular/core';
import { LoginSessionData } from 'app/shared/models/session-data.model';
import { RouterService } from 'app/shared/services/router.service';
import { SessionUtilityService } from 'app/shared/services/session-utility.service';
import { sessionKeys, StorageService } from 'app/shared/services/storage.service';
import { TokenService } from 'app/shared/services/token.service';
@Injectable({ providedIn: 'root' })
export class SessionService {
public constructor(
private routerService: RouterService,
private sessionUtilityService: SessionUtilityService,
private storageService: StorageService,
private tokenService: TokenService
) {}
/* This is called when receiving the Broadcast Channel message IsAnotherTabLoggedIn
* In here, you should retrieve all of your state/session/memory/token data. */
public getLoginSessionData(): LoginSessionData {
return {
refreshToken: this.tokenService.getRefreshToken(),
token: this.tokenService.getAccessToken()
};
}
/***** Do NOT call this method directly. It should be called only by the BroadcastChannelService. *****/
public async loginViaSession(broadCastData: LoginSessionData): Promise<void> {
if (!this.tokenService.isLoggedIn()) {
this.loadLoginSessionData(broadCastData);
setTimeout((): void => {
this.sessionUtilityService.isSessionLogin = false;
this.routerService.routeToFirstPage();
}, 500); // Timeout to allow route guards to do their work
}
}
// In here is where you would load all of your state/session/memory/token data.
public loadLoginSessionData(data: LoginSessionData): void {
this.tokenService.storeTokens(data.token, data.refreshToken);
}
}The method loadLoginSessionData is where you will wire up the data for the new browser tab. How you do this will depend on the specifics of your app. We also pause a bit here (500ms) so that any needed route guards can execute. The route guard we are concerned about here is a new SessionLoginGuard.
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate } from '@angular/router';
import { SessionUtilityService } from 'app/shared/services/session-utility.service';
@Injectable({
providedIn: 'root'
})
export class SessionLoginGuard implements CanActivate {
public constructor(
private sessionUtilityService: SessionUtilityService
) {}
public canActivate(route: ActivatedRouteSnapshot): boolean {
if (this.sessionUtilityService.isSessionLogin) {
this.sessionUtilityService.originalRoute = route;
return false;
}
return true;
}
}This guard will run for any route we attach it to. You will want to attach it to all routes for which you want a user to be able to go during browser tab duplication. In this guard, we store the route the user is requesting so that we can later route a user to it. This is needed because you would usually have a Login route guard on such routes as well. Due to the asynchronous nature of the broadcast channel communication, we may be denied routing to the requested page (by the Login route guard) because the new tab hasn’t been wired up with the user’s login (token) data yet. Therefore, this new route guard must run first. Here is an example (eg. app-routing.module.ts) file:
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from 'app/shared/guards/auth.guard';
import { SessionLoginGuard } from 'app/shared/guards/session-login.guard';
/* eslint-disable @typescript-eslint/no-explicit-any */
const routes: Routes = [
{
canActivate: [SessionLoginGuard, AuthGuard],
loadChildren: (): any => import('./pages/products/products.module')
.then((module: any) => module.ProductsModule),
path: '/products',
title: 'Products'
}
];
/* eslint-enable @typescript-eslint/no-explicit-any */
@NgModule({
exports: [RouterModule],
imports: [RouterModule.forRoot(routes)]
})
export class AppRoutingModule {}Because the SessionLoginGuard is listed before the AuthGuard(the Login route guard) it will run first.
You can see the code above in the SessionUtilityService example for storing the route.
Routing the User to the Requested Page
Let’s now take a look at how the user actually gets to their requested page (aka the URL from the original browser tab they duplicated). For this, let’s take a look into a method from a custom RouterService.
@Injectable({ providedIn: 'root' })
export class RouterService {
public routeToFirstPage(): void {
if (this.tokenService.isLoggedIn()) {
if (this.sessionUtilityService.originalRoute !== undefined) {
const url: string = this.sessionUtilityService.originalRoute.url[0].path;
const queryParams: Params = this.sessionUtilityService.originalRoute.queryParams;
this.sessionUtilityService.originalRoute = undefined;
if (queryParams !== undefined && Object.keys(queryParams)?.length > 0) {
this.router.navigate([url], { queryParams });
} else {
this.routeToUrl(`/${url}`);
}
} else {
this.routeToUrl(`/defaultPage`);
}
} else {
this.routeToUrl(`/loginPage`);
}
}
public async routeToUrl(url: string): Promise<boolean> {
if (url === undefined) {
return undefined;
}
if (parameters !== undefined) {
return await this.router.navigate([url, parameters], extras);
}
return await this.router.navigateByUrl(url);
}
}Here, in routeToFirstPage, we perform the following steps:
1) Make sure the user is logged in.
2) If there is a saved route from the SessionLoginGuard, parse the route and take the user there.
3) If there was no saved route, take the user to the default landing page of the app.
4) If the user was not logged in, take them to the login page.
Voila!
Logging Out all Browser Tabs
Every app will be different in how it performs a user logout, but once we have the paradigm of allowing multiple, logged-in browser tabs, we must ensure that all of them are logged out when the user logs out of one of them. Let’s pretend you have a LogoutService something like this:
import { Injectable } from '@angular/core';
import { BroadcastMessageType } from 'app/shared/enums/broadcast-messages.enum';
import { BroadcastChannelService } from 'app/shared/services/broadcast-channel.service';
import { RouterService } from 'app/shared/services/router.service';
import { SessionLogoutService } from 'app/shared/services/session-logout.service';
@Injectable({ providedIn: 'root' })
export class LogoutService {
public constructor(
private broadcastChannelService: BroadcastChannelService,
private routerService: RouterService,
private sessionLogoutService: SessionLogoutService,
) {}
/***** NOTE: any code put in here should also be mirrored into SessionLogoutService.logoutViaSession ******/
public logout(): void {
this.broadcastChannelService.postMessage({
type: BroadcastMessageType.Logout
});
// Do other logout & cleanup things
this.sessionLogoutService.clearSessionStorage();
this.routerService.routeToUrl(`/loginPage`);
}
}In the logout method, we are broadcasting the final of our broadcast channel messages: “Logout”. This will be handled in all other open browser tabs (of our app) in the handleMessage method of the BroadcastChannelService (shown near the top). As a refresher:
case BroadcastMessageType.Logout:
if (this.tokenService.isLoggedIn()) {
this.sessionLogoutService.logoutViaSession();
}
break;We cannot call the same logoutmethod of the LogoutService. Otherwise, the “Logout” broadcast message would be broadcast again and each tab would be in an infinite loop. So instead, we create a separate SessionLogoutService.
import { Injectable } from '@angular/core';
import { RouterService } from 'app/shared/services/router.service';
@Injectable({ providedIn: 'root' })
export class SessionLogoutService {
public constructor(private routerService: RouterService) {}
public clearSessionStorage(): void {
/* Clear things in memory, sessionStorage, app state etc that you don't
want hanging around after a user logs out. */
}
/***** Do NOT call this method directly. It should be called only by the BroadcastChannelService. *****/
public logoutViaSession(): void {
// Do cleanup things:
this.clearSessionStorage();
this.routerService.routeToUrl(`/loginPage`);
}
}Logging In all Browser Tabs
One last thing that you may wish to consider is that if you have multiple browser tabs open for your app and they are all at the login page (maybe because one of them logged out and, subsequently, so did the other tabs) you might want to log them all back into your app once one of them has logged in. We have the plumbing done for that already, and it’s as simple as executing the following code after your user has successfully logged in:
// Tell other tabs to login
this.broadcastChannelService.postMessage({
data: this.sessionService.getLoginSessionData(),
type: BroadcastMessageType.Login
});Magic!!
Conclusion
Being able to broadcast messages to all browser tabs running your app is very powerful. It can help you auto-login and auto-logout multiple tabs. It can also help you transfer data & state of your entire application. This makes opening (duplicating) a browser tab a seamless experience for your users. This is something all great apps do and I encourage you to do it for your app as well. Happy coding!
