npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

tab-bridge

v0.4.0

Published

Zero-dependency TypeScript library for real-time state synchronization across browser tabs, with leader election and cross-tab RPC

Readme

npm version bundle size TypeScript license GitHub stars

Getting Started · API · React · Zustand · Jotai · Redux · DevTools · Next.js · Architecture · Examples · Live Demo

Why tab-bridge?

When users open your app in multiple tabs, things break — stale data, duplicated WebSocket connections, conflicting writes.

tab-bridge solves all of this with a single function call:

const sync = createTabSync({ initial: { theme: 'light', count: 0 } });

Every tab now shares the same state. One tab is automatically elected as leader. You can call functions across tabs like they're local. No server needed.

✨ Feature Highlights

⚡ State Sync

LWW conflict resolution with batched broadcasts and custom merge strategies

👑 Leader Election

Bully algorithm with heartbeat monitoring and automatic failover

📡 Cross-Tab RPC

Fully typed arguments, Promise-based calls with callAll broadcast support

🔄 Atomic Transactions

transaction() for safe multi-key updates with abort support

⚛️ React Hooks

7 hooks built on useSyncExternalStore — zero-tear concurrent rendering

🛡️ Middleware Pipeline

Intercept, validate, and transform state changes before they're applied

💾 State Persistence

Survive page reloads with key whitelisting and custom storage backends

🐻 Zustand / Jotai / Redux

First-class integrations — tabSync, atomWithTabSync, tabSyncEnhancer

🔧 DevTools Panel

Floating <TabSyncDevTools /> with state inspection, tab list, and event log

📦 Zero Dependencies

Native browser APIs only, ~4KB gzipped, fully tree-shakable


📦 Getting Started

npm install tab-bridge
import { createTabSync } from 'tab-bridge';

const sync = createTabSync({
  initial: { theme: 'light', count: 0 },
});

// Read & write — synced to all tabs instantly
sync.get('theme');          // 'light'
sync.set('theme', 'dark'); // → every tab updates

// Subscribe to changes
const off = sync.on('count', (value, meta) => {
  console.log(`count is now ${value} (${meta.isLocal ? 'local' : 'remote'})`);
});

// Leader election — automatic
sync.onLeader(() => {
  const ws = new WebSocket('wss://api.example.com');
  return () => ws.close(); // cleanup when leadership is lost
});

// Cross-tab RPC
sync.handle('double', (n: number) => n * 2);
const result = await sync.call('leader', 'double', 21); // 42

📖 API Reference

createTabSync<TState, TRPCMap>(options?)

The single entry point. Returns a fully-typed TabSyncInstance.

const sync = createTabSync<MyState>({
  initial: { theme: 'light', count: 0 },
  channel: 'my-app',
  debug: true,
});

| Option | Type | Default | Description | |:-------|:-----|:--------|:------------| | initial | TState | {} | Initial state before first sync | | channel | string | 'tab-sync' | Channel name — only matching tabs communicate | | transport | 'broadcast-channel' | 'local-storage' | auto | Force a specific transport layer | | merge | (local, remote, key) => value | LWW | Custom conflict resolution | | leader | boolean | LeaderOptions | true | Leader election config | | debug | boolean | false | Enable colored console logging | | persist | PersistOptions | boolean | false | State persistence config | | middlewares | Middleware[] | [] | Middleware pipeline | | onError | (error: Error) => void | noop | Global error callback |

Instance Methods

sync.get('theme')                       // Read single key
sync.getAll()                           // Read full state (stable reference)
sync.set('theme', 'dark')              // Write single key → broadcasts to all tabs
sync.patch({ theme: 'dark', count: 5 }) // Write multiple keys in one broadcast

// Atomic multi-key update — return null to abort
sync.transaction((state) => {
  if (state.count >= 100) return null;  // abort
  return { count: state.count + 1, lastUpdated: Date.now() };
});
const off = sync.on('count', (value, meta) => { /* ... */ });
off(); // unsubscribe

sync.once('theme', (value) => console.log('Theme changed:', value));

sync.onChange((state, changedKeys, meta) => { /* ... */ });

sync.select(
  (state) => state.items.filter(i => i.done).length,
  (doneCount) => updateBadge(doneCount),
);

// Debounced derived state — callback fires at most once per 200ms
sync.select(
  (state) => state.items.length,
  (count) => analytics.track('item_count', count),
  { debounce: 200 },
);
sync.isLeader()                // → boolean
sync.getLeader()               // → TabInfo | null

sync.onLeader(() => {
  const ws = new WebSocket('wss://...');
  return () => ws.close();     // Cleanup on resign
});

const leader = await sync.waitForLeader(); // Promise-based
sync.id                        // This tab's UUID
sync.getTabs()                 // → TabInfo[]
sync.getTabCount()             // → number

sync.onTabChange((tabs) => {
  console.log(`${tabs.length} tabs open`);
});
sync.handle('getServerTime', () => ({
  iso: new Date().toISOString(),
}));

const { iso } = await sync.call('leader', 'getServerTime');
const result  = await sync.call(tabId, 'compute', payload, 10_000);

// Broadcast RPC to ALL other tabs and collect responses
const results = await sync.callAll('getStatus');
// results: Array<{ tabId: string; result?: T; error?: string }>
sync.ready      // false after destroy
sync.destroy()  // graceful shutdown, safe to call multiple times

🔷 Typed RPC

Define an RPC contract and get full end-to-end type inference — arguments, return types, and method names are all checked at compile time:

interface MyRPC {
  getTime: { args: void;                     result: { iso: string } };
  add:     { args: { a: number; b: number }; result: number };
  search:  { args: string;                   result: string[] };
}

const sync = createTabSync<MyState, MyRPC>({
  initial: { count: 0 },
});

sync.handle('add', ({ a, b }) => a + b);          // args are typed
const { iso } = await sync.call('leader', 'getTime'); // result is typed
const results = await sync.call(tabId, 'search', 'query'); // string[]

🛡️ Middleware

Intercept, validate, and transform state changes before they're applied:

const sync = createTabSync({
  initial: { name: '', age: 0 },
  middlewares: [
    {
      name: 'validator',
      onSet({ key, value, previousValue, meta }) {
        if (key === 'age' && (value as number) < 0)  return false;   // reject
        if (key === 'name') return { value: String(value).trim() };  // transform
      },
      afterChange(key, value, meta) {
        analytics.track('state_change', { key, source: meta.sourceTabId });
      },
      onDestroy() { /* cleanup */ },
    },
  ],
});

💾 Persistence

State survives page reloads automatically:

// Simple — persist everything to localStorage
createTabSync({ initial: { ... }, persist: true });

// Advanced — fine-grained control
createTabSync({
  initial: { theme: 'light', tempData: null },
  persist: {
    key: 'my-app:state',
    include: ['theme'],        // only persist these keys
    debounce: 200,             // debounce writes (ms)
    storage: sessionStorage,   // custom storage backend
  },
});

⚛️ React

First-class React integration built on useSyncExternalStore for zero-tear concurrent rendering.

import {
  TabSyncProvider, useTabSync, useTabSyncValue, useTabSyncSelector,
  useIsLeader, useTabs, useLeaderInfo, useTabSyncActions,
} from 'tab-bridge/react';
<TabSyncProvider options={{ initial: { count: 0 }, channel: 'app' }}>
  <App />
</TabSyncProvider>
function Counter() {
  const { state, set, isLeader, tabs } = useTabSync<MyState>();

  return (
    <div>
      <h2>Count: {state.count}</h2>
      <button onClick={() => set('count', state.count + 1)}>+1</button>
      <p>{isLeader ? '👑 Leader' : 'Follower'} · {tabs.length} tabs</p>
    </div>
  );
}
function ThemeDisplay() {
  const theme = useTabSyncValue<MyState, 'theme'>('theme');
  return <div className={`app ${theme}`}>Current theme: {theme}</div>;
}
function DoneCount() {
  const count = useTabSyncSelector<MyState, number>(
    (state) => state.todos.filter(t => t.done).length,
  );
  return <span className="badge">{count} done</span>;
}
function LeaderIndicator() {
  const isLeader = useIsLeader();
  if (!isLeader) return null;
  return <span className="badge badge-leader">Leader Tab</span>;
}
function TabList() {
  const tabs = useTabs();
  return <p>{tabs.length} tab(s) open</p>;
}
function LeaderDisplay() {
  const leader = useLeaderInfo();
  if (!leader) return <p>No leader yet</p>;
  return <p>Leader: {leader.id}</p>;
}
function IncrementButton() {
  const { set, patch, transaction } = useTabSyncActions<MyState>();
  return <button onClick={() => set('count', prev => prev + 1)}>+1</button>;
}

Components using only useTabSyncActions never re-render due to state changes — perfect for write-only controls.


🐻 Zustand

One-line integration for Zustand stores — all tabs stay in sync automatically.

npm install zustand
import { create } from 'zustand';
import { tabSync } from 'tab-bridge/zustand';

const useStore = create(
  tabSync(
    (set) => ({
      count: 0,
      theme: 'light',
      inc: () => set((s) => ({ count: s.count + 1 })),
      setTheme: (t: string) => set({ theme: t }),
    }),
    { channel: 'my-app' }
  )
);

// That's it — all tabs now share the same state.
// Functions (actions) are never synced, only data.

| Option | Type | Default | Description | |:-------|:-----|:--------|:------------| | channel | string | 'tab-sync-zustand' | Channel name for cross-tab communication | | include | string[] | — | Only sync these keys (mutually exclusive with exclude) | | exclude | string[] | — | Exclude these keys from syncing (mutually exclusive with include) | | merge | (local, remote, key) => value | LWW | Custom conflict resolution | | transport | 'broadcast-channel' | 'local-storage' | auto | Force a specific transport | | debug | boolean | false | Enable debug logging | | onError | (error) => void | — | Error callback | | onSyncReady | (instance) => void | — | Access the underlying TabSyncInstance for RPC/leader features |

const useStore = create(
  tabSync(
    (set) => ({
      count: 0,
      theme: 'light',
      localDraft: '',       // won't be synced
      inc: () => set((s) => ({ count: s.count + 1 })),
    }),
    {
      channel: 'my-app',
      exclude: ['localDraft'],   // keep this key local-only
    }
  )
);

Compose with Zustand's persist middleware — order doesn't matter:

import { persist } from 'zustand/middleware';

const useStore = create(
  persist(
    tabSync(
      (set) => ({
        count: 0,
        inc: () => set((s) => ({ count: s.count + 1 })),
      }),
      { channel: 'my-app' }
    ),
    { name: 'my-store' }
  )
);

Use onSyncReady to access the underlying TabSyncInstance for RPC, leader election, and other advanced features:

let syncInstance: TabSyncInstance | null = null;

const useStore = create(
  tabSync(
    (set) => ({ count: 0 }),
    {
      channel: 'my-app',
      onSyncReady: (instance) => {
        syncInstance = instance;

        instance.handle('getCount', () => useStore.getState().count);

        instance.onLeader(() => {
          console.log('This tab is now the leader');
          return () => console.log('Leadership lost');
        });
      },
    }
  )
);

🧪 Jotai

Synchronize individual Jotai atoms across browser tabs with zero boilerplate.

npm install tab-bridge jotai

Quick Start

import { atomWithTabSync } from 'tab-bridge/jotai';

const countAtom = atomWithTabSync('count', 0, { channel: 'my-app' });
const themeAtom = atomWithTabSync('theme', 'light', { channel: 'my-app' });

Use them like regular atoms — useAtom(countAtom) works as expected. Changes are automatically synced to all tabs.

Options

| Option | Type | Default | Description | |---|---|---|---| | channel | string | 'tab-sync-jotai' | Channel name for cross-tab communication | | transport | 'broadcast-channel' \| 'local-storage' | auto-detect | Force a specific transport | | debug | boolean | false | Enable debug logging | | onError | (error: Error) => void | — | Error callback | | onSyncReady | (instance: TabSyncInstance) => void | — | Access underlying instance |

How It Works

Each atom creates its own createTabSync instance scoped to ${channel}:${key}. The instance is created when the atom is first subscribed to and destroyed when the last subscriber unmounts.

Derived Atoms

Derived atoms work out of the box:

import { atom } from 'jotai';

const doubledAtom = atom((get) => get(countAtom) * 2);
// Automatically updates when countAtom syncs from another tab

🏪 Redux

Synchronize your Redux (or Redux Toolkit) store across browser tabs via a store enhancer.

npm install tab-bridge redux       # or @reduxjs/toolkit

Quick Start

import { configureStore } from '@reduxjs/toolkit';
import { tabSyncEnhancer } from 'tab-bridge/redux';

const store = configureStore({
  reducer: { counter: counterReducer, theme: themeReducer },
  enhancers: (getDefault) =>
    getDefault().concat(tabSyncEnhancer({ channel: 'my-app' })),
});

Every dispatch that changes state is automatically synced. Remote changes are merged via an internal @@tab-bridge/MERGE action — no reducer changes needed.

Options

| Option | Type | Default | Description | |---|---|---|---| | channel | string | 'tab-sync-redux' | Channel name | | include | string[] | — | Only sync these top-level keys (slice names) | | exclude | string[] | — | Exclude these top-level keys | | merge | (local, remote, key) => unknown | LWW | Custom conflict resolution | | transport | 'broadcast-channel' \| 'local-storage' | auto-detect | Force transport | | debug | boolean | false | Debug logging | | onError | (error: Error) => void | — | Error callback | | onSyncReady | (instance: TabSyncInstance) => void | — | Access underlying instance |

Selective Sync

tabSyncEnhancer({
  channel: 'my-app',
  include: ['counter', 'settings'],  // only sync these slices
  // exclude: ['auth'],              // or exclude specific slices
})

Design Decision: State-Based Sync

The enhancer diffs top-level state keys after each dispatch rather than replaying actions. This guarantees consistency regardless of reducer purity and works with any middleware stack.


🔧 DevTools

A floating development panel for inspecting tab-bridge state, tabs, and events in real time.

import { TabSyncDevTools } from 'tab-bridge/react';

function App() {
  return (
    <TabSyncProvider options={{ initial: { count: 0 }, channel: 'app' }}>
      <MyApp />
      {process.env.NODE_ENV === 'development' && <TabSyncDevTools />}
    </TabSyncProvider>
  );
}

Features

| Tab | Description | |---|---| | State | Live JSON view of current state + manual editing (textarea → Apply) | | Tabs | Active tab list with leader badge and "you" indicator | | Log | Real-time event stream — state changes, tab joins/leaves |

Props

| Prop | Type | Default | Description | |---|---|---|---| | position | 'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left' | 'bottom-right' | Panel position | | defaultOpen | boolean | false | Start expanded |

Tree-shakeable — if you never import TabSyncDevTools, it won't appear in your production bundle.


📘 Next.js

Using tab-bridge with Next.js App Router? Since tab-bridge relies on browser APIs, all usage must be in Client Components.

// app/providers/tab-sync-provider.tsx
'use client';

import { TabSyncProvider } from 'tab-bridge/react';

export function AppTabSyncProvider({ children }: { children: React.ReactNode }) {
  return (
    <TabSyncProvider options={{ initial: { count: 0 }, channel: 'my-app' }}>
      {children}
    </TabSyncProvider>
  );
}
// app/layout.tsx
import { AppTabSyncProvider } from './providers/tab-sync-provider';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html><body>
      <AppTabSyncProvider>{children}</AppTabSyncProvider>
    </body></html>
  );
}

Full guide: See docs/NEXTJS.md for SSR safety patterns, hydration mismatch prevention, useEffect initialization, and Zustand integration with Next.js.


🚨 Error Handling

Structured errors with error codes for precise catch handling:

import { TabSyncError, ErrorCode } from 'tab-bridge';

try {
  await sync.call('leader', 'getData');
} catch (err) {
  if (err instanceof TabSyncError) {
    switch (err.code) {
      case ErrorCode.RPC_TIMEOUT:    // call timed out
      case ErrorCode.RPC_NO_LEADER:  // no leader elected yet
      case ErrorCode.RPC_NO_HANDLER: // method not registered on target
      case ErrorCode.DESTROYED:      // instance was destroyed
    }
  }
}

// Global error handler
createTabSync({ onError: (err) => Sentry.captureException(err) });

🏗️ Architecture

How State Sync Works

How Leader Election Works


🔧 Advanced

import { createChannel } from 'tab-bridge';

createTabSync({ transport: 'local-storage' });

const channel = createChannel('my-channel', 'broadcast-channel');

All messages include a version field. The library automatically ignores messages from incompatible protocol versions, enabling safe rolling deployments — old and new tabs can coexist without errors.

import { PROTOCOL_VERSION } from 'tab-bridge';
console.log(PROTOCOL_VERSION); // 1
createTabSync({ debug: true });

Outputs colored, structured logs:

[tab-sync:a1b2c3d4] → STATE_UPDATE { theme: 'dark' }
[tab-sync:a1b2c3d4] ← LEADER_CLAIM { createdAt: 1708900000 }
[tab-sync:a1b2c3d4] ♛ Became leader

For library authors or advanced use cases, all internal modules are exported:

import {
  StateManager, TabRegistry, LeaderElection, RPCHandler,
  Emitter, createMessage, generateTabId, monotonic,
} from 'tab-bridge';

💡 Examples

🎯 Interactive Demos

Try these demos live — open multiple tabs to see real-time synchronization in action:

| Demo | Description | Features | |:-----|:-----------|:---------| | Collaborative Editor | Multi-tab real-time text editing | State Sync, Typing Indicators | | Shopping Cart | Cart synced across all tabs + persistent | State Sync, Persistence | | Leader Dashboard | Only leader fetches data, followers use RPC | Leader Election, RPC, callAll | | Full Feature Demo | All features in one page | Everything |

Code Examples

const auth = createTabSync({
  initial: { user: null, token: null },
  channel: 'auth',
  persist: { include: ['token'] },
});

auth.on('user', (user) => {
  if (user) showDashboard(user);
  else      redirectToLogin();
});

function logout() {
  auth.patch({ user: null, token: null }); // logout everywhere
}
const sync = createTabSync({
  initial: { messages: [] as Message[] },
});

sync.onLeader(() => {
  const ws = new WebSocket('wss://chat.example.com');

  ws.onmessage = (e) => {
    const msg = JSON.parse(e.data);
    sync.set('messages', [...sync.get('messages'), msg]);
  };

  return () => ws.close(); // cleanup on leadership loss
});
interface NotifyRPC {
  notify: {
    args: { title: string; body: string };
    result: void;
  };
}

const sync = createTabSync<{}, NotifyRPC>({ channel: 'notifications' });

sync.onLeader(() => {
  sync.handle('notify', ({ title, body }) => {
    new Notification(title, { body });
  });
  return () => {};
});

await sync.call('leader', 'notify', {
  title: 'New Message',
  body: 'You have 3 unread messages',
});
interface CartState {
  items: Array<{ id: string; name: string; qty: number }>;
  total: number;
}

function Cart() {
  const { state, set } = useTabSync<CartState>();

  const itemCount = useTabSyncSelector<CartState, number>(
    (s) => s.items.reduce((sum, i) => sum + i.qty, 0),
  );

  return (
    <div>
      <h2>Cart ({itemCount} items)</h2>
      {state.items.map(item => (
        <div key={item.id}>
          {item.name} × {item.qty}
        </div>
      ))}
    </div>
  );
}

🌐 Browser Support

| | Browser | Version | Transport | |:--|:--------|:--------|:----------| | 🟢 | Chrome | 54+ | BroadcastChannel | | 🟠 | Firefox | 38+ | BroadcastChannel | | 🔵 | Safari | 15.4+ | BroadcastChannel | | 🔷 | Edge | 79+ | BroadcastChannel | | ⚪ | Older browsers | — | localStorage (auto-fallback) |


MIT © serbi2012

If this library helped you, consider giving it a ⭐