Multi Tab/Window Messaging in JavaScript

I recently implemented a JavaScript only communication system for sending messages between windows/tabs (just referred to as window for the remainder of this document) for Ariba.com. We have a very heavy-handed approach that requires identifying each window and queueing messages in the order that are sent, no matter the originating window. And the final kicker is that it needs to work on some older browsers.

We settled on a shared communication channel using the browser local storage, or cookies when local storage is unavailable. Since each window has its own UI thread processing the JavaScript, we also had to consider race conditions with reading and writing to the shared data store. Unfortunately, our solution required server-side logic for tab detection, so it is not applicable outside of Ariba. However, there was a lot of good learning to share, so I simplified the communication channel and rewrote the code to not need to keep track of the originating window.

This article introduces a system that can be used to send messages between windows using only JavaScript. This technique has some drawbacks to consider before using: it only allows one message to be on the stack at a time, it does not preserve message order (although, they tend to stay in order), and it does not guarantee message delivery (closing a window before all messages have been sent). But it should be fine for loss-tolerant operations, such as sending a message alerting the user that another window about to logout.

How do it…

The full code is available at window_messenger.js and a Multi Tab/Window Messaging in JavaScript is setup for testing the code.

The first part of the code initializes the data reader/writer and starts a timer to poll for new messages:

init: function() {
	// 1) start the interval timer (called a cycle) to begin polling
	// the message string
	setInterval(WindowMessenger.readMessage, CYCLE_TIMEOUT);

	// 2) decide which engine to use for communication
	// WindowMessengerUseCookie can be set to true to force cookie use
	if (w.localStorage && !w.WindowMessengerUseCookie) {
		// 2a) using HTML5 local storage. session storage was not
		// used because a new session is created in each tab
		this.reader = function(sName) {
			return localStorage.getItem(sName) || '';
		};
		this.writer = function(sName, sValue) {
			return localStorage.setItem(sName, sValue);
		};
	}
	else {
		// 2b) using cookie fallback
		this.reader = function(sName) {
			return readCookie(sName);
		};
		this.writer = function(sName, sValue) {
			return createCookie(sName, sValue, FORWARD_SLASH,
					w.location.hostname, COOKIE_EXPIRES);
		};
	}

	// 3) if a message is already in the system, I'm going to take ownership
	// of it. If I am not the sender, the sender may clear it before me.
	sLastSentMessage = WindowMessenger.reader(KEY_NAME);
	iCycleCount = 1;
},

The postMessage function is called whenever a message should sent through the system:

postMessage: function(sMessage) {
	// 1) current message
	var sCurrentMessage = WindowMessenger.reader(KEY_NAME);

	// 2) is this message being spammed? (should I send it at all)
	if (sLastSentMessage === sMessage || sCurrentMessage === sMessage) {
		fnLog('Not posting "' + sMessage +'", as it was recently sent.');
	}
	else {
		// 3) is there still a message
		if (sCurrentMessage) {
			// 3a) try again in a little while
			fnLog('Queueing message "' + sMessage + '"');
			callDelayed(WindowMessenger.postMessage, [sMessage], CYCLE_TIMEOUT);
		}
		else {
			// 3b) no messages, add this one
			fnLog('Sending post "' + sMessage + '"');
			WindowMessenger.writer(KEY_NAME, sMessage);

			// 4) code against race
			// ensure race condition did not occur and message was written
			setTimeout(function() {
				if (sMessage ===  WindowMessenger.reader(KEY_NAME)) {
					// 4a) record the sent message
					sLastSentMessage = sMessage;
				}
				else {
					fnLog('Race-Condition, re-posting');
					// 4b) revert and re-post
					iCycleCount = 0;
					callDelayed(WindowMessenger.postMessage,
							[sMessage], CYCLE_TIMEOUT);
				}
			}, 500);

			iCycleCount = 1;
		}
	}
},

The requestMessage function is called periodically to poll for new messages, and to remove messages the current window originated:

readMessage: function() {
	// 1) read the window message
	var sCurrentMessage = WindowMessenger.reader(KEY_NAME),
			i, sClosureMessage;

	fnLog('Read window message, contains - ' + sCurrentMessage);

	// 2) is there a message, if not clear last received message
	if (!sCurrentMessage) {
		sLastReceivedMessage = '';
	}
	// 3) is current message equal to the last message
	else if (sCurrentMessage === sLastSentMessage) {
		// 3a) increment the message process counter
		iCycleCount++;

		fnLog('Processing message "' + sCurrentMessage + '" (' +
				iCycleCount + ')');

		// 3b) when max cycles exceeded, remove message key
		if (iCycleCount > NUM_CYCLES_TO_PERSIST_MESSAGE) {
			// refresh the window message to reduce chance of race
			WindowMessenger.writer(KEY_NAME, '');
			sClosureMessage = '' + sLastSentMessage;

			fnLog('Removing message - "' + sCurrentMessage + '"');

			/*
			 3c) code against race
			 */
			setTimeout(function() {
				// 3a) if message key is still in window message,
				// then race occurred
				if (sClosureMessage === WindowMessenger.reader(KEY_NAME)) {
					sClosureMessage = '';
					iCycleCount = 0;
				}
			}, 500);
		}
	}
	// 4) is current message not equal to the last message received,
	// then process it.
	else if (sCurrentMessage !== sLastReceivedMessage) {
		sLastReceivedMessage = sCurrentMessage;
		i = aSubscribers.length - 1;
		fnLog('Passing message to subscribers - ' + sCurrentMessage);

		while (i >= 0) {
			aSubscribers[i--](sCurrentMessage);
		}
	}
},

The subscribe function should be passed a callback function for handling messages. Here is a simple callback to log all messages:

WindowMessenger.subscribe(function(sMessage) {
    console.log(sMessage);
});

How it works…

We’ll break the discussion up into three sections: init, post message, and read message.

init

The init function is called automatically after the window_messenger script is loaded. It first starts a poll timer (default is five seconds) to periodically call the read_message function. Then the code sets up a reader and writer functions to handle reading and writing message internally. The code prefers to use window.localStorage, but will use a cookie when local storage is unavailable.

Lastly, it checks to see if there is already a message and updates variables to assume the message originated from this window. Since the messages contain no information indicating what window originated them, there is no way to know if the message originated from this window or another. And if it did originate from this window on a previous page, nobody would ever know to remove the message, since previous page JavaScript variables are lost. So, we assume it originated from this window, and if it did not, everything is still fine, because the real originating window will remove the message before the timer on this window does, and the current window will detect that and move on.

We make one assumption about this window not needing to process the message since it just reloaded.

post message

The postMessage function should be passed the string message to send to the other windows. It first checks if the message is currently being sent or sent by another window to prevent spamming messages. Next, when there is already a message being sent, we queue the message using a simple timeout, so it will try to send again after the next read cycle.

Otherwise, we write our message and follow it by a short timeout (this should be much shorter than a read cycle and is hardcoded to 500ms). The callback in the timeout double checks that the message is still the value in the data store, ensuring that another window did accidentally overwrite the current message. If a race occurred, we let the other message remain for the next cycle and queue up the message that we tried to write.

read message

The readMessage function is called periodically, and handles reading and removing messages. It first grabs the current message, then makes sure the message is not empty. When the current message is empty we set the sLastReceivedMessage variable to an empty string, so the system can accept the same message again if another window sends it. When the current message is not equal to sLastReceivedMessage, we send the message to all subscribing callback functions and update sLastReceivedMessage to the current message value.

When the current message equals sLastSentMessage, we activate logic to handle detecting when to remove the message. A cycle counter is incremented to indicate the number of times this code block executed for the current message. When the counter is greater than the number of cycles to persist (default is two cycles), the message is cleared from the data store.

To remove the message, an empty string is written to the shared data store. As with postMessage a short timeout is used to assert there was not a race to update the data store. When a race occurs, we do not need to do anything, because the next read message cycle will attempt to remove the message again. When there is no race, we go ahead and reset the counter and sLastSentMessage.

If another window writes the exact same message as the current window is trying to remove during the 500ms timeout, then the race logic will be triggered. This is most likely okay, because the duplicate message was just sent and processed by the other windows, so it is probably a spammy message.

There’s more…

As you can see, sending messages between windows can be done relatively simply, if it doesn’t need to be bullet-proof. I think this could be improved by using an SQL database or similar system in the browser, where messages can be written and removed in order, without worrying about race conditions. Alternatively, a set of 10 to 20 keys could be written to, writing to the next available slot and read out in order. This would allow multiple messages to be written and processed using the same technologies already implemented, but would require more complicated message tracking and handling of race conditions.