My battle with browser tabs

Simple beginnings

Cole Chamberlain
Jun 3, 2016 · 5 min read

I recently built a React / Redux app which leveraged JWT access tokens and refresh tokens. Alongside these requirements, the specification called for a monitoring system that detected when users would become idle and log them out after a configurable level of inactivity. Simple enough I thought at outset. There were many “gotchas” along this path, the biggest being synchronization of user auth state and idleness across browser tabs or windows.

Cookies and Refresh Tokens

I ended up choosing cookies for the persistence mechanism due to some required integration with legacy platforms. Cookie’s attribute of getting autoposted to the server on every browser postback made them a natural fit for token persistence. I initially attempted, with great effort, to make refresh tokens long lived and only hand out 1 per user (subject)-fingerprint combination. If a user tried to perform a refresh and a token was found matching their fingerprint and username, it would update the refresh token with the new one, invalidating the previous one, and causing many issues. I created a simple universal auto-refresh package, jwt-autorefresh, to run every postback or auth state change. When run, it calculates the offset time between now and when the current access token expires and schedules user provided refresh token logic to run in advance of the tokens expiration time (with some random jitter threshold so not everything is trying to refresh at the same time). This alleviated many of the issues but we were still experiencing random feeling logouts. The nail in the coffin to these issues was making the refresh tokens short lived (same time frame as access token expiration), and only inserting, never upserting them.

Cue browser tabs

When diving deep into the issue of what was wrong with my first attempt at the refresh token system, one of the things I discovered was that different clients of the same browser were wreaking havoc on each other. One tab would perform a refresh, and update its cookies meanwhile other tabs were humming along using their tokens from Redux state. Because the refresh tokens were being invalidated on the server, the in-memory token of other tabs would inevitably attempt an authorized API request and get rejected with a 401. The tab would then interpret this 401 as though their auth tokens (and associated cookie) were no longer valid, delete the cookie, and boot the user to the login screen.

First Attempt: Cookie Polling

I wrote a mechanism to check cookies every couple seconds to see if there were any new ones arriving from other tabs. I was able to cutout almost all the issues by using a custom Redux middleware in conjunction with always reading from cookies when making an auth or api request instead of Redux. The problem at this point was virtually non existent.

Idle hands

For user idle state monitoring I wrote the package redux-idle-monitor and it worked great — for a single tab (fixed now). After going through some qa cycles it was found that users were getting logged out during active times because other tabs were not recognizing that the user was active and would eventually kill their cookie and log them out. This was a pretty catastrophic issue. The first fix for it was a local storage polling solution that was similar to the fix used for auth synchronization. It seemed like all the issues were solved until it went through QA under IE / Edge. Neither of them were getting any of the synchronization updates because there is an open bug where local storage is broken on that entire line of browsers, other tabs cannot read the state of another tabs local storage. Surprising, this is…

Light at the end

In looking for a fix to these issues I stumbled across this amazing article, and associated npm module local-storage, and was shocked to find out there is a built-in API that emits storage events when changes are triggered on a tab. I rewired all the code to use this module instead of cookie polling and things seemed flawless except that the IE / Edge issues persisted on.

The solution

In the end I ended up creating localsync.

localsync is a simple interface to allow creation of a key based object passing channel between multiple browser tabs. It internally uses localstorage events to push to other tabs and falls back gracefully to cookie polling. It has many configuration options with sane defaults that allow the consumer the ability to tweak the polling frequency, etc. It supports N instances so you can sync as many things as you like.

Here is what a typical configuration could look like with localsync:

import localsync from ‘localsync’/** Create an action that will trigger a sync to other tabs. */
const action = (userID, first, last) => ({ userID, first, last })
/** Create a handler that will run on all tabs that did not trigger the sync. */
const handler = (new, old, url) => {`Another tab at url ${url} switched user from “${old.first} ${old.last}” to “${new.first} ${new.last}”.`)
// do something with new.userID
/** Create a synchronizer. localsync supports N number of synchronizers for different things across your app. */
const usersync = localsync(‘user’, action, handler)
/** Start synchronizing. */
/** IE / Edge do not support local storage across multiple tabs. localsync will automatically fallback to a cookie polling mechanism here. You don’t need to do anything else. */
console.warn(‘browser doesnt support local storage synchronization, falling back to cookie synchronization.’)
/** Trigger an action that will get handled on other tabs. */
usersync.trigger(1, ‘jimmy’, ‘john’)
setTimeout(() => {
/** Trigger another action in 5 seconds. */
usersync.trigger(2, ‘jane’, ‘wonka’)
}, 5000)
setTimeout(() => {
/** If its still running, stop syncing in 10 seconds. */
}, 10000)

redux-idle-monitor now uses localsync internally to signal other tabs when a user becomes active and it’s days of woe are over. If you need to add a lock screen or automatic logout, check it out.

If you’ve got a similar path to mine, I’d highly recommend taking a look at localsync. For the 2.0 release, I am targeting WebRTC for cross-browser sync, with fallback to the two current states. Please send feedback to @noderaider on Twitter or open an issue on one of these libraries.

Cole Chamberlain

Written by

@noderaider on Twitter and GitHub.