When the same form is open in two tabs and both autosave to one storage key, whichever tab saved last wins, and the other tab’s work vanishes the next time the draft is restored — with no error anywhere.
Draft persistence and autosave covers the single-tab lifecycle. This page adds the second tab. The goal is not real-time collaborative editing; it is making sure two tabs never silently destroy each other’s input, and that the user always knows which tab holds the live draft.
Context and prerequisites
People open the same form twice more often than you would expect: a reopened tab from history, a link clicked in an email while the original is still open, a mobile browser restoring a tab it had suspended. With a single localStorage key per draft, the sequence is predictable:
- Tab A restores the draft and the user edits the address.
- Tab B restores the same draft and the user edits the phone number.
- Tab A autosaves (address edited, phone original).
- Tab B autosaves (address original, phone edited) — overwriting A’s address change.
Two browser primitives make coordination cheap. BroadcastChannel delivers messages to every same-origin context listening on a named channel, and is supported in every current browser. The storage event fires in other tabs when localStorage changes, which makes it a useful fallback and a cross-check. Neither needs a server.
The core pattern: a writer lease plus change notifications
The simplest correct policy is single writer: at any moment one tab owns the draft and may autosave it; other tabs show the draft read-only with a clear way to take over. Ownership is a lease announced over BroadcastChannel and written into storage so late-opening tabs can see it.
type Msg =
| { type: "claim"; tabId: string; at: number }
| { type: "saved"; tabId: string; version: number };
interface Lease { tabId: string; at: number }
export class DraftCoordinator {
readonly tabId = crypto.randomUUID();
private channel: BroadcastChannel;
private leaseKey: string;
constructor(private draftKey: string, private onLostOwnership: () => void,
private onRemoteSave: (version: number) => void) {
this.leaseKey = `${draftKey}:lease`;
this.channel = new BroadcastChannel(`draft:${draftKey}`);
this.channel.onmessage = (e: MessageEvent<Msg>) => this.handle(e.data);
// Fallback for contexts where a message was missed (e.g. a tab frozen by
// the browser while backgrounded): storage events are delivered on thaw.
addEventListener("storage", this.onStorage);
}
isOwner(): boolean {
const lease = this.readLease();
return lease?.tabId === this.tabId;
}
/** Take ownership — on first edit in this tab, or when the user clicks "edit here". */
claim(): void {
const lease: Lease = { tabId: this.tabId, at: Date.now() };
localStorage.setItem(this.leaseKey, JSON.stringify(lease));
this.channel.postMessage({ type: "claim", ...lease } satisfies Msg);
}
announceSave(version: number): void {
this.channel.postMessage({ type: "saved", tabId: this.tabId, version } satisfies Msg);
}
private handle(msg: Msg) {
if (msg.tabId === this.tabId) return;
if (msg.type === "claim") this.onLostOwnership(); // someone else is writing now
if (msg.type === "saved") this.onRemoteSave(msg.version);
}
private onStorage = (e: StorageEvent) => {
if (e.key === this.leaseKey && e.newValue) {
const lease = JSON.parse(e.newValue) as Lease;
if (lease.tabId !== this.tabId) this.onLostOwnership();
}
};
private readLease(): Lease | null {
try { return JSON.parse(localStorage.getItem(this.leaseKey) ?? "null"); }
catch { return null; } // storage blocked or corrupt
}
destroy() {
this.channel.close();
removeEventListener("storage", this.onStorage);
}
}
Wire it so the first input event in a tab calls claim() if the tab is not already the owner, the autosave function checks isOwner() before writing, and onLostOwnership switches the form into a read-only “this draft is being edited in another tab” state with an Edit here instead button that calls claim() and reloads the latest draft.
Step-by-step walkthrough
- Create one channel per draft key. Naming the channel after the draft means tabs editing different records never hear each other.
- Claim on first edit, not on load. A tab that is merely open should not steal ownership from the tab the user is typing in. Claiming lazily also means a restored background tab stays passive.
- Gate every autosave on
isOwner(). The lease in storage is the source of truth; the broadcast message is the fast notification. - Go read-only when ownership moves. Keep the user’s unsaved in-memory edits, disable autosave, and show a banner. If they choose to take over, merge their unsaved fields onto the latest stored draft using the conflict rules from resolving conflicts when restoring a draft.
- Announce each save with a version. Passive tabs use it to refresh their read-only view so it never shows stale data.
- Close the channel on teardown. An unclosed
BroadcastChannelkeeps the page out of the back/forward cache in some browsers and leaks the listener.
Failure modes and edge cases
1. The owning tab is closed without releasing
A closed tab cannot announce anything reliably — pagehide handlers may not run on mobile. Treat the lease as stale after a timeout (say 30 seconds without a heartbeat or save) and allow the next editing tab to claim it silently.
2. Frozen background tabs
Browsers freeze background tabs to save battery. A frozen tab misses broadcast messages and receives them in a burst, or not at all, when it thaws. Re-check isOwner() on visibilitychange to visible and on the resume event, not only in the message handler.
3. Storage blocked in private modes
If localStorage throws, the lease cannot be persisted. Fall back to broadcast-only coordination (last claimer wins) and degrade gracefully, as autosaving form drafts to localStorage does for the draft itself.
4. Field-level merge instead of single writer
If both tabs genuinely need to write, store per-field timestamps and merge field by field rather than writing whole drafts. That turns the overwrite in the first diagram into a merge where A’s address and B’s phone both survive — but it is more code, and conflicting edits to the same field still need a user decision.
5. Service workers and iframes
BroadcastChannel reaches same-origin iframes, workers and service workers too. Filter by tabId so an embedded preview frame of the same form does not claim the lease.
Verification checklist
Frequently Asked Questions
Can I use the storage event alone instead of BroadcastChannel?
Yes, and it works in older browsers, but you have to write to storage to send a message, and it never fires in the tab that made the change. BroadcastChannel is a cleaner message bus; the storage event is a useful backstop because it is delivered to tabs that were frozen when the broadcast went out.
Does this protect drafts stored in IndexedDB too?
The coordination is independent of where the draft lives. Keep the lease in localStorage for its synchronous read, and store the draft body in IndexedDB if it is large; the channel messages carry only ids and versions.
Should the read-only tab hide the form entirely?
No. Show the current draft read-only so the user can see it, explain why editing is paused, and offer one button to continue here. Hiding the form makes a user who simply switched tabs think their work is gone.