Skip to content

Multi-Window Tauri Apps: State Sync Made Simple

You open a settings panel in a separate Tauri window. Switch the theme to "dark." Close the window. The main window is still in "light." The user is confused. So are you.

I hit this exact problem while building a multi-window Tauri app. After the third attempt at hand-rolling event sync — and the third round of subtle bugs — I decided to build a proper solution.


The Problem With Multi-Window Tauri Apps

In Tauri, every window runs its own isolated JavaScript context. There's no shared memory, no global store, no magic bridge. When window A changes state, window B has no idea.

Many Tauri developers discover this the hard way. I certainly did. The single-window prototype works perfectly. Then you add a second window — and you need a sync mechanism.

Here's a reasonable first attempt at multi-window state sync:

typescript
// Manual multi-window sync — a reasonable first pass
let localRevision = 0;

const unlisten = await listen('settings-changed', async (event) => {
  const payload = event.payload as { revision: number };

  if (payload.revision <= localRevision) return;

  try {
    const snapshot = await invoke('get_settings');
    store.$patch(snapshot.data);
    localRevision = snapshot.revision;
  } catch (err) {
    console.error('Sync failed:', err);
  }
});

window.addEventListener('beforeunload', unlisten);

This looks fine. Revision tracking, cleanup on unmount, error handling — it's what a competent developer would write. For a single settings panel that changes once in a while, it works.

But two problems are hiding in this code, and they only surface under real-world conditions.

Problem 1: Race conditions on overlapping fetches

When multiple events arrive in rapid succession, each triggers an invoke() call. These are async IPC round-trips — and they don't resolve in order:

The fetch for revision 1 takes longer than revision 2. By the time it resolves, the store already has newer data — but $patch overwrites it with the stale snapshot. There's no guard against this because localRevision was checked before the fetch, not after.

Problem 2: IPC flooding without coalescing

When a user drags a slider or types rapidly, the backend might broadcast dozens of events per second. Each event triggers an IPC round-trip — serialize, cross the Rust-JS bridge, deserialize, serialize the response, cross back, deserialize. At 100 events, that's 100 round-trips.

This isn't a bug in the traditional sense — the code is correct. But it creates unnecessary load on the IPC bridge and can make the UI feel sluggish.

These two problems are fundamentally connected: both stem from the fact that every invalidation event triggers its own fetch. Solving them properly — with coalescing, revision ordering after fetch, and lifecycle management — takes ~150–200 lines of careful concurrency code per store. (For a detailed comparison with existing alternatives, see the Technical Architecture Ranking.)

I looked for an existing library that handled this cleanly for Tauri. I didn't find one. So I built one.

state-sync — What It Is and How It Works

state-sync is a small, framework-agnostic library for Tauri v2 that handles the hard parts of multi-window state synchronization. You provide three things:

  1. A subscriber — tells the engine "something changed"
  2. A provider — fetches the canonical snapshot from the backend
  3. An applier — writes the snapshot into your local state store

The engine handles everything else: ordering, coalescing, lifecycle, error recovery.

The Invalidation-Pull Model

Here's the key insight that makes state-sync resilient: events don't carry data.

Think of it like push notifications on your phone. The notification says "You have new mail." It doesn't contain the email text. You open the mail app and pull the current state. If a notification was lost — the next one still catches you up.

The same principle applies here:

This makes the system naturally tolerant to real-world IPC problems:

  • Lost event? The next invalidation will have a higher revision — sync catches up.
  • Duplicate event? Revision gate filters it: event.revision <= localRevision → skip.
  • Out-of-order delivery? Only newer revisions are accepted.
  • Large payload? It's fetched once on demand, not duplicated in every event.

Here's the same settings sync from above, rewritten with state-sync:

typescript
import { createTauriRevisionSync } from '@statesync/tauri';
import { createPiniaSnapshotApplier } from '@statesync/pinia';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { useSettingsStore } from './stores/settings';
import type { Settings } from './types';

const handle = createTauriRevisionSync<Settings>({
  topic: 'settings',
  listen,
  invoke,
  eventName: 'state-sync:invalidation',
  commandName: 'get_settings',
  applier: createPiniaSnapshotApplier(useSettingsStore(), {
    mode: 'patch',
    omitKeys: ['localUiFlag'],
  }),
  throttling: { debounceMs: 50, throttleMs: 200 },
  onError(ctx) {
    console.error(`[${ctx.phase}] ${ctx.error}`);
  },
});

await handle.start();
// Coalescing, revision ordering, lifecycle — all handled.

~15 lines. Zero race conditions. Built-in coalescing, throttling, and structured error reporting. Full IPC roundtrip: p50 2 ms, p99 5 ms. With coalescing, 100 rapid events collapse to 2 IPC calls.

AspectManual approachstate-sync
Boilerplate~150–200 lines per store~15 lines
Race conditionsOn youRevision ordering + coalescing
IPC flooding (many events → many fetches)On youCoalesced — at most one queued fetch behind an in-flight one
Late joinerOn youAutomatic initial refresh on start()
Error handlingtry/catch8 structured phases: subscribe, invalidation, refresh, getSnapshot, apply, protocol, throttle, start
Test coverageOn you370+ tests including IPC jitter, event drops, mutex contention

Let's break down the key claims from that table.

Under the Hood: Three Key Design Decisions

Revisions: 21 Lines That Solve Ordering

state-sync doesn't use complex distributed ordering primitives (vector clocks, Lamport timestamps, CRDTs). Revisions are monotonic u64 counters encoded as strings — and comparison is just a few lines:

typescript
export function compareRevisions(a: Revision, b: Revision): -1 | 0 | 1 {
  if (a === b) return 0;
  if (a.length !== b.length) return a.length < b.length ? -1 : 1;
  return a < b ? -1 : 1;
}

Why strings? JavaScript's Number.MAX_SAFE_INTEGER is 2^53 - 1. A u64 goes up to 2^64 - 1. String encoding avoids precision loss without requiring BigInt — and it's JSON/IPC-friendly out of the box.

The length-first comparison trick works because canonical decimal representations have no leading zeros: "9" is always shorter than "10", so length comparison gives the correct order. Same-length strings are compared lexicographically, which matches numeric order for same-digit-count numbers.

Coalescing: 100 Events, 2 Fetches

As described above, rapid events without protection mean 100 round-trips for 100 events. state-sync's engine uses two booleans — refreshInFlight and refreshQueued — to solve this:

Without coalescing — every event triggers a separate IPC round-trip:

With state-sync — coalescing collapses rapid events into minimal fetches:

While a fetch is in-flight, all incoming events collapse into a single refreshQueued = true flag. When the in-flight fetch completes, the engine runs exactly one more cycle — which always gets the latest snapshot. The result: at most one queued fetch behind the in-flight one, regardless of how many events arrive.

On top of this, you can optionally configure throttling with debounceMs and throttleMs for additional rate control — useful when you want to wait for a "quiet period" before refreshing.

There's also shouldRefresh — a predicate that lets you filter invalidation events before they enter the coalescing pipeline. A common use case is self-echo filtering: when window A triggers a state change, Rust broadcasts to all windows — including A. Since A already has the latest state, it can skip the redundant fetch:

typescript
import { getCurrentWindow } from '@tauri-apps/api/window';

// Filter out self-triggered invalidations
shouldRefresh: (event) => event.sourceId !== getCurrentWindow().label,

No Write Path — By Design

state-sync only handles the read direction: backend → all windows. There's no store.set(), no sync.push(), no write API.

This is intentional. The Rust backend is the single source of truth. Windows write state by calling Tauri commands (invoke), which update the backend and trigger invalidation. state-sync then distributes the result.

This eliminates an entire class of problems: no write conflicts, no merge logic, no divergent state between windows. One source of truth, many consumers.

Full Example: Tauri + Pinia

Let's see all of this come together in a complete setup.

Rust Backend (Tauri v2)

rust
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, State};
use serde::{Deserialize, Serialize};

#[derive(Clone, Serialize, Deserialize)]
pub struct Settings {
    pub theme: String,
    pub font_size: u32,
    pub language: String,
}

pub struct AppState {
    pub settings: Settings,
    pub revision: u64,
}

#[tauri::command]
pub fn get_settings(state: State<'_, Mutex<AppState>>) -> serde_json::Value {
    let s = state.lock().unwrap(); // simplified — production code should handle poisoned mutex
    serde_json::json!({
        "revision": s.revision.to_string(),
        "data": s.settings,
    })
}

#[tauri::command]
pub fn update_settings(
    app: AppHandle,
    state: State<'_, Mutex<AppState>>,
    settings: Settings,
    source_window: String,
) -> Result<(), String> {
    let mut s = state.lock().unwrap(); // simplified for brevity
    s.settings = settings;
    s.revision += 1;

    app.emit("state-sync:invalidation", serde_json::json!({
        "topic": "settings",
        "revision": s.revision.to_string(),
        "sourceId": source_window, // enables self-echo filtering via shouldRefresh
    })).map_err(|e| e.to_string())
}

To wire these up, add .invoke_handler(tauri::generate_handler![get_settings, update_settings]) and .manage(Mutex::new(AppState { ... })) to your Tauri builder. See the full example on GitHub.

Frontend (Vue + Pinia)

typescript
// Production wrapper — adds lifecycle cleanup and omitKeys vs. the minimal example above
import { createTauriRevisionSync } from '@statesync/tauri';
import { createPiniaSnapshotApplier } from '@statesync/pinia';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { useSettingsStore } from './stores/settings';
import type { Settings } from './types';

export async function setupSettingsSync() {
  const store = useSettingsStore();

  const handle = createTauriRevisionSync<Settings>({
    topic: 'settings',
    listen,
    invoke,
    eventName: 'state-sync:invalidation',
    commandName: 'get_settings',
    applier: createPiniaSnapshotApplier(store, {
      mode: 'patch',
      omitKeys: ['isSettingsPanelOpen'],
    }),
    onError(ctx) {
      console.error(`[settings-sync] ${ctx.phase}:`, ctx.error);
    },
  });

  await handle.start();
  window.addEventListener('beforeunload', () => handle.stop());

  return handle;
}

Every window calls setupSettingsSync() on mount. When any window updates settings via invoke('update_settings', ...), all windows sync automatically. Late joiners get the current state on start(). The omitKeys option keeps local UI flags (like "is the settings panel open") out of sync.

Framework-Agnostic via Structural Typing

Every adapter defines a minimal structural interface instead of importing the framework:

typescript
// From @statesync/pinia — no Pinia import!
export interface PiniaStoreLike<State extends Record<string, unknown>> {
  $id?: string;
  $state: State;
  $patch(patch: Partial<State> | ((state: State) => void)): void;
}

This means zero runtime dependency on Pinia, Zustand, or any other library. It also means you can test adapters with plain objects — no mocks required. And the applier swap is a one-line change — the rest of the sync config stays the same:

typescript
// Pinia
const applier = createPiniaSnapshotApplier(piniaStore);

// Zustand — only this line changes
const applier = createZustandSnapshotApplier(zustandStore);

Eight framework adapters are available: Redux, Zustand, Jotai, MobX, Pinia, Valtio, Svelte, and Vue (reactive + ref). The Tauri transport adapter handles event subscription and snapshot fetching.

The core engine is ~3 KB gzipped. Each adapter adds ~0.8 KB.

Getting Started

bash
npm install @statesync/core @statesync/tauri @statesync/pinia
# or with your framework adapter of choice:
# npm install @statesync/core @statesync/tauri @statesync/zustand

If you need retry logic for flaky IPC calls, drop down to the core API and wrap the provider:

typescript
import { createRevisionSync, withRetry } from '@statesync/core';
import { createTauriInvalidationSubscriber, createTauriSnapshotProvider } from '@statesync/tauri';
import { createPiniaSnapshotApplier } from '@statesync/pinia';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';

const handle = createRevisionSync<Settings>({
  topic: 'settings',
  subscriber: createTauriInvalidationSubscriber({ listen, eventName: 'state-sync:invalidation' }),
  provider: withRetry(
    createTauriSnapshotProvider({ invoke, commandName: 'get_settings' }),
    { maxAttempts: 3, initialDelayMs: 500 },
  ),
  applier: createPiniaSnapshotApplier(store, { mode: 'patch' }),
});

For offline persistence, the @statesync/persistence package adds localStorage/IndexedDB caching with compression, schema migration, and cross-tab sync via BroadcastChannel.

When to Use state-sync (and When Not To)

Good fit:

  • Multi-window Tauri apps syncing settings, auth state, theme, user preferences
  • Any scenario with a single source of truth (backend) and multiple consumers (windows/tabs)
  • Projects using any of the supported frameworks (or plain JS — the core has no framework dependency)

Not the right tool:

  • Collaborative editing (Google Docs-style) — you need CRDTs like Yjs or Automerge
  • Single-window apps — no sync needed
  • High-frequency realtime data (games, trading tickers) — state-sync is designed for UI state, not 60fps data streams

For desktop apps with multiple windows, the invalidation-pull model strikes a practical balance: simple enough to reason about, resilient enough for production.


Remember that settings panel? You switch the theme to "dark," close the window, and the main window updates instantly. No stale state, no race conditions, no manual event wiring. Just a revision bump on the Rust side and a single getSnapshot pull on the JS side.

The best state synchronization is the kind that doesn't send data in events. It sends a signal — and lets the consumer pull what it needs, when it needs it.

state-sync is MIT-licensed, has 370+ tests, and the core is ~3 KB gzipped. If you're building a multi-window Tauri app, give it a look.

Links:

Released under the MIT License.