Workspace Isolation: choosing between strict and tab-based

The two ways to manage context switching in products with multiple workspaces and making a choice between both.

Workspace Isolation: choosing between strict and tab-based

If your product allows multiple workspaces, i.e. enabling users to belong to and work across multiple businesses, organizations, accounts, or whatever internal name you use, there are two ways you can design the switching between the workspaces. (A term I found that represents this is called Workspace Isolation).

  1. Strict Workspace Isolation: Users can work on one workspace at a time. All browser tabs are associated with that workspace. They can’t open a different workspace in a different tab. To work on a different workspace concurrently, they need to either use a new browser or open a new window in incognito. Product examples: Twitter (X), Stripe.
  2. Tab-Based Workspace Isolation: Users can work on multiple workspaces in different tabs. This allows users to maintain separate contexts in individual tabs, enabling them to switch between different workspaces without affecting the content or state of other tabs. Product examples: Slack, Cloudflare, and Google Cloud.

Strict vs Tab-based workspace isolation

There are different reasons why products choose one over the other. Strict Workspace Isolation for example is easier to design. The identifier for the workspace being accessed can be stored in the session, meaning every new tab, i.e., every instance of that session is tied to the workspace.

Strict Workspace Isolation can also be a choice because it prevents users from mistakenly mixing workspaces and entering the wrong data in the wrong workspace.

One other reason is to intentionally prevent users from working on multiple workspaces in the same window at the same time. Think Netflix for example. There is no point in allowing users to use different profiles in the same window.

When we started Engage, no consideration was given to how users switch organizations (our term for workspaces). Identifiers for the logged-in user and accessed organization were stored in session anyway, so it was strict isolation by default. With time, it became obvious we needed to change to tab-based for a couple of reasons.

  1. Working across multiple organizations in the same window is easier. This is especially useful for users who have a dedicated “dev” organization and want to compare data across the “dev” and another organization at the same time. There is no point in forcing users to work on one organization per window.
  2. With tab-based isolation, the identifier to the organization (workspace) in context is in the URL instead of the session. It is therefore easier to directly link to a specific organization from external sources like emails. When we send notification emails that contain links to the user’s dashboard, it makes sense that the user can directly access the organization in context from the email.
  3. Tab-based isolation helps avoid data mixups during context switching. With strict isolation, a tab can be syncing data for a previous workspace, unaware there has been a switch to a different organization in a different tab. Imagine a user opens the organization “dev” on two tabs: the dashboard on the first tab and an email editor on the second tab. In the background, the email editor tab is syncing input to the backend to save it as a draft. If the user switches the first tab to a different organization, the second tab, unaware of the switch will continue to send data for “dev” to the new organization. There are a couple of ways to prevent this. One common pattern is to send some form of internal notification across all opened tabs (with WebSockets for example) and force a refresh. (Twitter does this). But this introduces a new set of complexity.

Planning for a switch to tab-based isolation

Handling Statelessness

The first work is to figure out how to make the workspace identifier stateless, meaning every request (every tab) should have information about the workspace it represents. The simplest way to do this is to pass the identifier in the URL.
There are two common patterns for this:

  1. Use a query parameter. e.g. If you visit the Google Cloud Console, you will notice the URL is something like this: where awesome-co-1234 is the project (workspace) identifier.
  2. Use the URL path. e.g. If you check Slack on the web, you will notice the URL is something like this: where T11B6D2M40K is the workspace identifier.

Using a query parameter is easier to implement. Your backend routing system will remain the same. You only need to update your frontend links to include the workspace identifier in the query. But you need to be careful when adding the query parameter to links that already include query parameters, especially the ones that are automatically generated; paginated links for example.

At the end of the day, we went for the URL path option. It is tidier. But not just that; we can add a middleware to our routing system for that path to do organization-level stuff like authorization.  (See addendum for a routing example in Express.js).

It’s easy to miss the hard work that needs to be done to change all the links on the front end to reflect the new path but it cannot be undermined. There are a couple of paths to take here, the hardest one being manually rewriting all links to reflect the new path. This is what I will recommend you do.

<!--<a href=“/settings”>Settings</a>-->
<a href=“/{{workspace_id}}/settings”>Settings</a>

You may be tempted to want to use the base tag, but it does not work on hyperlinks ( a tags) alone. It also rewrites other resource tags (link, script and img). If you must use it, ensure all your a tags are relative URLs and other resource tags are absolute URLs.

<base href="/{{workspace_id}}/">
<!-- Relative URL. Bad. Base will rewrite it to /workspace_id/styles.css -->
<!--<link rel="stylesheet" type="text/css" href="styles.css">-->
<!-- Use absolute URL instead: -->
<link rel="stylesheet" type="text/css" href="/styles.css">
<!-- Do same for img and script tags -->

<!-- Absolute URL. Bad. Base wont rewrite it -->
<!--<a href="/settings">Settings</a>-->
<!-- Use Relative URL instead: -->
<a href="settings">Settings</a>
For <base> to work, ensure your <a> tags are relative URLs and other resource tags are absolute URLs 

One other option is to use JavaScript to rewrite hyperlinks on page load. I won’t recommend this either. It is tacky, can lead to performance issues, and is not foolproof. Anything can happen to the script’s execution, leaving a trail of broken links.

If you have linked to pages on your app’s dashboard from other sources, transactional emails for example, do not forget to update those links as well. I recommend always using templates for your transactional emails so that it’s easy to update without touching your codebase.


At this point, you should be fine. For us, there was still the issue of Webpack and OAuth Redirects.

Webpack and Vue Routing

Webpack has a publicPath configuration option that lets you set where your resource assets will be loaded from. Vue uses this to set a BASE_URL environmental variable that you can use in a couple of places including setting the base parameter option in  Vue Router. Vue Router in turn uses this to correctly link router-link tags.

// vue.config.js
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  publicPath: '/inbox'
  // ...
// router.js
const router = new VueRouter({
  mode: 'history',
  base: process.env.BASE_URL,
<router-link :to="{ path: '/' }">Home</router-link>
<!-- will become: <a href="/inbox">Home</a> -->

Ideally, we would want the router-links to be prefixed with the workspace ID like this <a href="/workspace_id/inbox">Home</a>. But there is no way to dynamically set publicPath because the assets are already compiled at runtime.

How we resolved this was to set the ID as a global variable dynamically when importing the script and use that to set base for Vue Router without using the BASE_URL environmental variable.

const router = new VueRouter({
  mode: 'history',
  base: `/${window.workspaceId}/inbox/`,

OAuth redirects

Some of our integrations use OAuth 2. Users are directed to the service (aka provider) for authorization and back to the workspace. OAuth 2 lets you add a redirect URL that the user is redirected to after authorization. The challenge here is that the URL must exactly match what has been manually set on the provider. This means that if I have set as Engage’s OAuth redirect URL on Stripe, I can’t use as the redirect URL parameter when redirecting for authorization. And because the IDs are unique to every organization, they cannot be dynamically set on the provider’s side. We cannot also pass the ID as a query parameter in the redirect URL like this

OAuth has a state parameter that can be added to the query when redirecting for authorization. The parameter is returned as is and looks like an ideal way to pass the workspace ID to the service provider and back. However, this defeats the goal of the state parameter. It is supposed to be a random unguessable string generated by the consumer so that when returned, it can be confirmed.

But we can make the state parameter work. And this is what we did. Instead of a random string, we generated a JWT string with expiry using the workspace ID as one of the payload parameters. After redirection, we can verify the token and use the workspace ID it returns to redirect to the right workspace.

Reach customers through email, SMS, in-app messaging, and push notifications to deliver the right message in the right way.

Addendum: Routing example with Express.js

We start by creating a route for the workspace’s URL in our entry script. In the code example below, I am matching the first path with 24 characters which is a mix of numbers and alphabets ([0-9a-fA-F]{24}). I am assigning that to the parameter: workspace. (See Route Parameters in the Express.js docs).

// index.js
const express = require('express')
const app = express()
// ...

// Create a route for workspaces
const Workspaces = require('./routes/workspaces')

app.use('/:workspace([0-9a-fA-F]{24})', Workspaces)
app.get('/logout', (req, res) => {
  // ...
// ...other routes here

This means my workspace URLs will follow the pattern: Of course, yours can follow any other URL pattern. For example, we can use /:workspace(wp[0-9]+) for a path like this

In the file that handles the workspace routing, we can then add the subroutes to match. In the code example below, I added two sub-routes as examples.

  • - Dashboard
  • - Settings

I also added a middleware that every request that gets here passes through. This is a good place to do authorization and set other parameters needed by the subroutes.

// routes/workspaces.js
const express = require('express')
const router = express.Router({ mergeParams: true })
// ...

// Middleware for authorization and other things
router.use((req, res, next) => {
  // stuff
  res.locals.workspaceId = req.params.workspace

// now the workspace subroutes
router.use('/', Dashboard)
router.use('/settings', Settings)

Two things to note here:

  1. mergeParams: true: Because we are doing nested routing, this makes params from the parent route (i.e, our workspace param) available in all the subroutes. In the Dashboard route, for example, I can reference req.params.workspace
  2. res.locals.workspaceId: This makes workspaceId available in my views when using a view engine in Express. For example, using liquidJS as my view engine, I can easily reference the workspaceId in views like this: <a href="/{{workspaceId}}/settings">Settings</a>