Cross-Tab Communication in JavaScript using a BroadcastChannel

We discuss how to do cross-tab communication with a BroadcastChannel, the browser's native API to communicate across browser instances. It's rather similar to using a SharedWorker, just without the worker.

Published
Dec 12, 20
Duration
18m 49s

Developer, dog lover, and burrito eater. Currently teaching AdonisJS, a fully featured NodeJS framework, and running Adocasts where I post new lessons weekly. Professionally, I work with JavaScript, .Net C#, and SQL Server.

Adocasts

Burlington, KY

In the last two lessons, we discussed how to approach building a cross-tab messaging application using LocalStorage and a SharedWorker. Today we'll be finishing this series by doing the same with a BroadcastChannel. The BroadcastChannel is a native browser API for communicating across browser instances (tabs, windows, etc).

The main downside of using a BroadcastChannel is its browser compatibility. It doesn't support Internet Explorer, which isn't a big deal anymore, but it also doesn't support Safari and that very well could be a deal-breaker.

What We'll Be Building

Interested in what we'll be building? You can check it out here. You'll be able to post a message from one tab and receive it in another. Messages sent from one tab will appear to the right and will be blue. Messages received from other tabs will be gray and to the left, similar to texting.

Following Along?

If you'd like to follow along, I have a repository set up with where we'll be starting in this lesson. It's got the project structure and boilerplate markup for our page so we don't have to waste time adding that in. So, go ahead and clone it down, install the dependency (serve), and run npm run serve to get the dev server started up.

https://github.com/jagr-co/cross-tab-communication/tree/00_StartHere

In this lesson, we'll be working within pages -> broadcastchannel -> index.html.

Cross-Tab Communication

Let's start with why you're here, cross-tab communication using a BroadcastChannel. To begin we'll need to create a new BroadcastChannel instance that we can work with. To do this we can call the BroadcastChannel constructor, and pass in a unique name for our messaging channel, which we'll call ours message_broadcast.

const broadcast = new BroadcastChannel('message_broadcast');
Copied!

Listening for Messages

Now that we have our BroadcastChannel instance created for our message_broadcast channel, we can register an onmessage event handler to our instance.

broadcast.onmessage = function(event) {
  console.log({ event });
}
Copied!

Anytime a message is posted using our broadcast channel the function we passed to the onmessage property will be called and passed an event for the message. Within this event will be a property called data. This data property will contain whatever message we posted, whether it be a string, array, object, etc.

The main caveat to this onmessage handler is that it will not be called from the browser instance posting the message. So it's important to note you'll need to separately handle the message on the tab or window that sent the message.

Posting Messages

Posting messages using our BroadcastChannel is easy and painless. All we need to do is call the method postMessage on our broadcast channel. The argument we pass into the method is our message contents, and it can be whatever datatype we need it to be.

broadcast.postMessage({ message: 'I can be whatever' });
Copied!

With this, our message will be sent to all open tab instances with a BroadcastChannel instance using the same channel name. Unlike our SharedWorker, our BroadcastChannel will work out of the box with all our browser instances.

Getting Started

Now that we have a general understanding of how to use a BroadcastChannel, let's move forward with our messaging application.

On our page currently, we have our HTML markup containing our navigation, heading, a form for sending messages, a form for clearing messages, a placeholder element, and an ordered list for displaying our messages.

Let's go ahead and bind some constant globals for these items.

const messagesEl = document.getElementById('messages');
const messagesPlaceholderEl = document.getElementById('messagesPlaceholder');
const messagesKey = 'messages';
const senderIdKey = 'senderId';
const mutations = {
  ADD: 'ADD',
  SET: 'SET'
};

let allMessages = [];
Copied!
  • messagesEl is our ordered list

  • messagesPlaceholderEl is our messages placeholder

  • messagesKey is the key we'll use to store our messages in LocalStorage. LocalStorage will only be used to persist messages on browser refresh.

  • tabSenderIdKey is the key we're going to save our browser instance's unique id under in SessionStorage.

  • mutations is an object we'll use to post and receive messages of particular actions, in this case adding a message and setting our entire message collection.

  • allMessages will be a collection of all our messages.

Next, let's get submit handlers bound to our send message and clear messages forms along with their handler functions. Additionally, let's also create our BroadcastChannel.

const broadcast = new BroadcastChannel('message_broadcast');
broadcast.onmessage = handleNewMessage;

document.forms.sendMessageForm.addEventListener('submit', handleSendMessage);
document.forms.clearMessagesForm.addEventListener('submit', handleClearMessages);

function handleSendMessage(event) {
  event.preventDefault();
}

function handleClearMessages(event) {
  if (event) event.preventDefault();
}

function handleNewMessage({ data }) {

}
Copied!

In addition to this, we'll need one more global variable called tabSenderId, this will be a unique ID we'll use to determine if a message was sent from the tab we're currently on. To start, let's add a utility method to the bottom of our script element to generate a unique ID. Let's also add our tabSenderId with our other global variables.

const tabSenderId = getUniqueId();

/***/

function getUniqueId() {
  return '_' + Math.random().toString(36).substr(2, 9);
}
Copied!

Now, we're also going to want to persist the generated tabSenderId so that when we refresh our browser tabs this remains the same. We'll use SessionStorage for this, which isn't shared across tabs. Let's create a helper function called getSenderId that will attempt to find a SessionStorage item with the key of our senderIdKey. If an item is found we'll use that value as our tabSenderId, otherwise we'll use our getUniqueId utility function to create a new unique ID that we'll then store in SessionStorage under the key of our senderIdKey.

const tabSenderId = getSenderId();

/***/

function getSenderId() {
  let senderId = sessionStorage.getItem(senderIdKey) || getUniqueId();
  sessionStorage.setItem(senderIdKey, senderId);
  return senderId;
}

function getUniqueId() {
  return '_' + Math.random().toString(36).substr(2, 9);
}
Copied!

Adding A Few Utilities

Before we go any further, let's add a few utilities that will help keep things concise from here out.

function hideEl(el) {
  el.classList.add('hidden');
}

function showEl(el) {
  el.classList.remove('hidden');
}

function parseArray(item) {
  if (!item || !item.length) return [];
  return !Array.isArray(item) ? JSON.parse(item) : item;
}

function setStringifiedStorageItem(key, value) {
  localStorage.setItem(key, value ? JSON.stringify(value) : value);
}

function getParsedStorageItem(storageKey) {
  let stringValue = localStorage.getItem(storageKey);
  return stringValue ? JSON.parse(stringValue) : stringValue;
}
Copied!

hideEl and showEl are pretty straightforward, but let's run through the rest.

  • parseArray will gracefully attempt to parse a stringified array, if needed

  • setStringifiedStorageItem will store an item in LocalStorage, stringifying it beforehand.

  • getParsedStorageItem will get an item from LocalStorage, parsing its contents beforehand.

Getting, Making, and Adding Message Helpers

Now that we have our storage and form listeners set up, we can move into adding some helpers to keep our message logic clear and concise.

Getting Messages

function getMessages() {
  const messages = getParsedStorageItem(messagesKey);
  return !Array.isArray(messages) ? [] : messages;
}
Copied!

This will get and return an array of our messages, or an empty array if we have none.

Making A Message

function makeMessage(message) {
  return {
    id: getUniqueId(),
    senderId: tabSenderId,
    message,
  };
}
Copied!

And this is when we want to make a new message. We'll have a unique ID per message, our tab's sender ID, and the message.

function makeMessageEl({ message, id, senderId }) {
  const li = document.createElement('li');
  const baseClassName = 'px-3 py-1 rounded';

  li.id = id;
  li.textContent = message;
  li.className =
    senderId == tabSenderId
      ? `${baseClassName} bg-blue-600 text-white place-self-end`
      : `${baseClassName} bg-gray-700 text-white place-self-start`;

  return li;
}
Copied!

We'll use this to create a message LI element to display our messages. If the message's senderId equals our tabSenderId, the message will be blue and aligned right. Otherwise, it'll be gray and aligned left.

Displaying Messages

function displayMessages(messages) {
  messages.forEach((msg) => messagesEl.appendChild(makeMessageEl(msg)));
}
Copied!

We'll use this to append our created message LI's from makeMessageEl to the DOM under our messages list.

Post Message

function postMessage(mutation, value) {
  const data = { mutation, value, messages: allMessages };
  handleNewMessage({ data });
  setStringifiedStorageItem(messagesKey, allMessages);
  broadcast.postMessage(data);
}
Copied!

We'll create a helper to send our messages on our BroadcastChannel. mutation will be the action we're performing (ADD, SET). 'ADD' will be when we're adding a single message and 'SET' will be used to set our entire message collection. value will be an array containing one message if we're adding and all our messages if we're setting.

We'll wrap everything into a data object so that we can easily call handleNewMessage ourselves when we post a message. We're calling handleNewMessage ourselves here because our onmessage handler won't be called for our message on the tab we send a message from.

Clearing All Messages

Next up, let's rig up the logic to clear out our messages by finishing our handleClearMessages function.

so, if you recall, earlier we created a handleClearMessages function that took an event. Let's adjust that method to the one below.

function handleClearMessages(event) {
  if (event) event.preventDefault();

  allMessages = [];
  postMessage(mutations.SET, allMessages);
}
Copied!

To make this function callable from our other methods, we'll check to see if we were given an event. If we were all we want to do is preventDefault.

Then we'll clear out our allMessages collection and post that to our other tabs using our SET mutation. This SET mutation will tell them to clear out their messages as well.

Handling New Messages

Now we're ready for our logic to handle new messages sent from the current and other tabs. Let's edit the handleNewMessage method we added earlier to the below.

function handleNewMessage({ data }) {
  const { mutation, value, messages } = data;

  allMessages = messages;

  messages.length
    ? hideEl(messagesPlaceholderEl)
    : showEl(messagesPlaceholderEl);

  switch(mutation) {
    case mutations.ADD:
      displayMessages(value);
      break;
    case mutations.SET:
      messagesEl.innerHTML = '';
      displayMessages(value);
      break;
  }
}
Copied!

Here we're spreading out our { mutation, value, messages } properties from our data object so we can directly reference them.

Next, we'll update our local allMessages collection so that it has the latest changes.

Then we determine whether to show or hide our placeholder element. If we have messages we'll hide them, otherwise, we'll show them.

Finally, we'll use a switch to determine which mutation was run and we can individually handle those as needed. When adding all we'll need to do is display the new message. When setting we'll need to clear out our ordered list's innerHTML before we can display the new messages.

Sending A New Message

Finally, we're ready to add the ability to send a new message. Let's adjust the handleSendMessage function we added earlier to the below.

function handleSendMessage(event) {
  event.preventDefault();

  const value = event.target.message.value;
  const message = makeMessage(value);
  allMessages.push(message);

  postMessage(mutations.ADD, [message]);

  event.target.reset();
  event.target.message.focus();
}
Copied!

We grab the value from the message form input, and make a new message matching our { id, senderId, message } object format. Next, we'll push our new message into our allMessages collection so it's up-to-date. Then we'll post our message using the ADD mutation so our other tabs know we've added a new message. Finally, we'll reset our form and refocus the message input.

Let's Test!

We can now finally test what we have! So open up two browser tabs pointing to the /broadcastchannel page. If you've had these pages open previously you'll want to go through and refresh each tab. Now, send a message and you should see that message gets added both for the tab you're on and the inactive tab. Huzzah!

Now refresh the page and you'll see why we aren't quite done. Our messages are saved in LocalStorage, but we aren't doing anything to actually populate them on load.

Populating Messages On Load

To do this, let's add one more function to our page called loadMessages.

function loadMessages() {
  allMessages = getMessages();
  postMessage(mutations.SET, allMessages);
}
Copied!

This gets our messages from LocalStorage, then makes use of our SET mutation to set the entire value of our messages. Our handleNewMessage event listener will take care of the rest.

Finally, let's call our loadMessages function.

loadMessages();
Copied!

Now refresh the page again and you should see your messages populating on load.

Summary

While we did a lot in this lesson, the actual code needed to communicate across tabs using our BroadcastChannel was super minimal. Of the three methods we covered, BroadcastChannel is definitely the most concise option. However, a lack of Safari support is definitely a downer. If Safari were to pick up support for the BroadcastChannel API in the future, this would definitely be the go-to approach.

That wraps it up for this series, thank you so much for reading/watching!

Join The Discussion! (0 Comments)

Please sign in or sign up for free to join in on the dicussion.

robot comment bubble

Be the first to Comment!