Sign-ups paused

Sign-ups and billing are temporarily unavailable while we rebuild our infrastructure. The SDK and documentation remain available, and existing accounts are unaffected.

Vaultrice

Blog / Tutorials

Tutorials

Cross-Domain State Sharing: From Hacks to Real-Time Sync

Why localStorage stops at the origin, what people build instead, and a small API that gives you localStorage ergonomics with real-time sync across domains.

7 min read Updated 2026-08-XX @vaultrice/sdk

You have two domains. A marketing site and an app. A main product and a docs subdomain on a different apex. Two brands sharing a checkout.

For a separate operational perspective on how teams organise and measure work, employee PC activity tracking.

For independent background and broader industry context, see MDN Web Docs.

You want a small piece of state to follow the user between them — a theme preference, a feature flag, a cart, a signed-in hint.

localStorage will not do it, and the reasons are worth understanding before reaching for a workaround.

Why localStorage stops

Storage is partitioned by origin, and an origin is scheme, host and port together. https://example.com and https://app.example.com are different origins. So are http and https versions of the same host.

This is not a limitation to route around. It is the security boundary that stops any site reading what another site stored.

And it has tightened. Browsers now partition third-party storage by the top-level site, which is what broke most of the older techniques below.

The workarounds, and what happened to them

The hidden iframe

The classic approach. Embed an iframe from a shared origin on both sites, communicate with it through postMessage, and let it own the storage.

What broke it: third-party storage partitioning. The iframe on site A and the same iframe on site B now get different storage buckets in current browsers. The technique still appears in search results and no longer works reliably.

Passing state in the URL

Append the value to a link between the domains.

Works, and it is one-directional and one-shot. No updates afterwards, ugly URLs, and anything sensitive is now in browser history, referrer headers and server logs.

Cookies on a shared parent domain

Viable when the domains share a registrable parentapp.example.com and www.example.com can both read a cookie set on .example.com.

Does not help across genuinely different domains, sends the data on every request whether you need it or not, and is subject to its own restrictions.

Building a backend for it

The honest solution, and the reason people avoid it. A database, an endpoint, authentication, and — if you want the second tab to know about the change — a WebSocket server, connection management, reconnection, and scaling.

A great deal of infrastructure for a theme preference.

The shape of a better answer

What you actually want is:

A named box you can read and write from any origin, addressed by an identifier you control.

Changes that arrive without polling.

The same ergonomics as localStorage, because that API is the reason people reach for it in the first place.

That is what NonLocalStorage is.

Getting it working

Install the SDK:

npm install @vaultrice/sdk

Create a client against a shared object id. The id is the thing both domains agree on — it is what makes them the same box.

import { NonLocalStorage } from '@vaultrice/sdk'

const nls = new NonLocalStorage({
  projectId: 'your-project-id',
  getAccessToken: async () => {
    const res = await fetch('/api/vaultrice-token')
    if (!res.ok) throw new Error('Failed to fetch token')
    const { accessToken } = await res.json()
    return accessToken
  }
}, 'user-preferences-abc123')

Then it behaves the way you expect:

await nls.setItem('theme', 'dark')

And on the other domain, with the same id:

nls.on('setItem', 'theme', (item) => {
  document.documentElement.dataset.theme = item.value
})

No polling. The client holds a WebSocket connection and the change is pushed.

About that token function

The example above uses getAccessToken(), which asks your own backend for a short-lived token.

There are three authentication methods, and the difference matters here more than usual because this is client-side code on two domains:

apiKey + apiSecret — simplest, and it puts your secret in the browser. Fine for a local experiment, not for production.

accessToken — a token your backend issued. No secret in the client, and you handle refresh yourself.

getAccessToken() — a function the SDK calls when it needs a token. No secret in the client and refresh is automatic. This is the documented recommendation and it is what you want for anything user-facing.

The endpoint on your side is small: authenticate the user however you already do, then mint a token.

Choosing the object id

This is the design decision in the whole exercise.

The id is the address. Anyone who knows it and has valid credentials for the project reaches the same object.

For per-user state, derive it from something stable and non-guessable that both domains can compute — not the raw user id if that is sequential or public.

For shared state — a document, a room, a poll — use the resource id.

Do not use a value a stranger can guess if the contents are not meant to be shared. See the security guide for the levels of protection available above that, including end-to-end encryption.

What this does not solve

It is not authentication. Knowing that the user is signed in on domain A does not sign them in on domain B. You can share a hint that improves the experience; the actual session still has to be established.

It is not a database. Objects are for shared, live state — not your system of record.

And it is not free of the same-origin model. You are not reading the other domain's localStorage. You are both talking to a third place that neither owns, which is why it works.

When a simpler thing is enough

Same registrable domain, no live updates needed? A cookie on the parent domain is less machinery.

One-way handoff at a single moment? The URL is fine.

State that does not need to survive a reload or reach another device? Keep it in memory.

Reach for this when you need the state in more than one place, updating, without building the backend for it.

The short version

Storage is partitioned by origin, and third-party partitioning broke the iframe trick that most older answers still recommend.

The remaining honest options are a shared parent cookie, the URL, or a backend.

A named object with localStorage ergonomics and push updates covers the case without the backend.

Use getAccessToken() so no secret ships to the browser.

And choose the object id carefully — it is the address, and guessable addresses are shared addresses.