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:

  1. Tab A restores the draft and the user edits the address.
  2. Tab B restores the same draft and the user edits the phone number.
  3. Tab A autosaves (address edited, phone original).
  4. 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.

Two tabs, one draft key, and a silent overwrite Tab A and tab B each read the same stored draft. Tab A edits the address and saves the whole draft. Tab B, which never saw that change, edits the phone number and saves its whole draft, overwriting the stored address with the original value. Next time the draft is restored, the address edit is gone and no error was ever shown. Tab A Storage Tab B restore draft v1 restore draft v1 save: address edited save: phone edited, old address next restore: address edit gone Whole-draft writes are the root cause: each tab writes fields it did not change, using values it read before the other tab's edit.

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

  1. Create one channel per draft key. Naming the channel after the draft means tabs editing different records never hear each other.
  2. 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.
  3. Gate every autosave on isOwner(). The lease in storage is the source of truth; the broadcast message is the fast notification.
  4. 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.
  5. Announce each save with a version. Passive tabs use it to refresh their read-only view so it never shows stale data.
  6. Close the channel on teardown. An unclosed BroadcastChannel keeps the page out of the back/forward cache in some browsers and leaks the listener.
Ownership moving from tab A to tab B Tab A owns the lease and autosaves. The user types in tab B, which writes a new lease to storage and broadcasts a claim message. Tab A receives the claim, stops autosaving, keeps its unsaved edits in memory and shows a banner saying the draft is being edited in another tab. If the user returns to tab A and clicks Edit here instead, tab A claims the lease, loads the latest stored draft and merges its unsaved fields onto it. Tab A owns the lease Autosave enabled. Lease in storage: { tabId: A }. user types in B Tab B claims Writes lease, broadcasts claim. B becomes the only tab allowed to write the draft. claim received Tab A goes read-only Autosave off, edits kept in memory. Banner: this draft is being edited in another tab. Edit here instead Tab A reclaims and merges Loads latest draft, merges its unsaved fields. Nothing typed in either tab is lost.

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.

Coordination strategies compared Last write wins has high risk of silent data loss, no implementation cost and confusing results. A single-writer lease has no silent loss, low cost and a clear read-only banner in passive tabs. Per-field merge has loss only for same-field conflicts, moderate cost, and lets both tabs keep editing. Strategy Silent loss Cost Experience Last write wins likely none edits vanish on restore Single-writer lease none low passive tabs go read-only Per-field merge same-field conflicts only moderate both tabs keep editing

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.


Related

← Draft Persistence and Autosave