# How Multi-Device Synchronization Works in Thunderbolt: PowerSync Architecture Explained

> Understand Thunderbolt multi-device synchronization with PowerSync. Explore offline-first sync, client-side encryption, and real-time replication for consistent data across all your devices.

- Repository: [Thunderbird/thunderbolt](https://github.com/thunderbird/thunderbolt)
- Tags: architecture
- Published: 2026-04-19

---

**Thunderbolt achieves multi-device synchronization through PowerSync, an offline-first sync engine built on SQLite and Web Workers that uses client-side encryption, device registration via recovery keys, and real-time bidirectional replication to keep data consistent across all connected devices.**

Thunderbolt, the modern email client from Thunderbird, implements robust multi-device synchronization to ensure seamless access to data across desktops and mobile devices. At the heart of this system lies **PowerSync**, a sophisticated sync engine that combines SQLite's reliability with Web Workers' performance. This article examines the complete technical architecture—from device registration to real-time data replication—based on the actual implementation in the `thunderbird/thunderbolt` repository.

## Three-Layer Architecture of Multi-Device Synchronization

The sync system is organized into three distinct layers, each handling specific responsibilities:

| Layer | Responsibility | Key Implementation |
|-------|---------------|---------------------|
| **Device onboarding** | Registers new devices, creates encryption recovery keys, and stores the *client key* (CK) in IndexedDB | [`src/hooks/use-sync-setup.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/hooks/use-sync-setup.ts) – state-machine wizard that executes `registerThisDevice`, `completeFirstDeviceSetup`, and `recoverWithKey` |
| **Sync enable/disable** | Persists the user's "Sync on/off" flag, connects or disconnects the PowerSync instance, and broadcasts changes to the UI | [`src/hooks/use-sync-enabled-toggle.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/hooks/use-sync-enabled-toggle.ts) stores the flag in `localStorage` and fires `powersync_sync_enabled_change` events |
| **PowerSync core** | Provides a singleton `PowerSyncDatabaseImpl` that wraps PowerSync with Drizzle for type-safe queries, manages connections, visibility-based reconnects, and initial-sync waiting | [`src/db/powersync/database.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/db/powersync/database.ts), [`src/db/powersync/schema.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/db/powersync/schema.ts), and [`shared/powersync-tables.ts`](https://github.com/thunderbird/thunderbolt/blob/main/shared/powersync-tables.ts) |

## Device Registration and Encryption Flow

Before data can synchronize, Thunderbolt must register the device securely using client-side encryption keys.

### First-Device Setup

When syncing is enabled for the first time, `useSyncSetup` orchestrates the registration through a series of API calls:

1. **`registerThisDevice`** – Creates a device record on the backend and returns whether the device is already trusted.
2. **`checkCanaryExists`** – Determines if any device already exists in the account.
3. If no canary exists, **`completeFirstDeviceSetup`** generates a 24-word recovery phrase and encrypts the *client key* (`CK`) locally in `src/crypto/key-storage`.

### Additional-Device Setup

If a canary exists (indicating another device is already registered), the system detects an existing "canary" via `checkCanaryExists` and initiates an approval workflow where the user must authorize the new device from an already-trusted device using `checkApprovalAndUnwrap`.

The wizard maintains all intermediate UI state in `useSyncSetup`'s reducer (lines 61‑98). Errors such as exceeding the device limit (HTTP 422) surface to the UI via the `SET_ERROR` action.

## The PowerSync Core Implementation

The synchronization engine centers on a singleton `PowerSyncDatabaseImpl` class that manages the SQLite database and cloud connection.

### Singleton Pattern and Initialization

The `getPowerSyncInstance()` function inspects the global `DatabaseInterface` singleton to retrieve the underlying PowerSync object (lines 49‑56 in [`src/db/powersync/database.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/db/powersync/database.ts)).

During initialization, `PowerSyncDatabaseImpl.initialize` builds platform-specific configuration options:

```typescript
// Default configuration uses SharedWorker for multi-tab support
const defaultConfig = {
  worker: 'sync.worker',
  // ...
};

// Safari/Tauri configuration uses WASQLiteOpenFactory with OPFSCoopSyncVFS
// because SharedWorkers are unsupported on iOS/Tauri
const safariConfig = {
  database: new WASQLiteOpenFactory({
    vfs: new OPFSCoopSyncVFS()
  })
};

```

Both configurations attach `encryptionMiddleware` to ensure every write passes through client-side encryption before persisting to the local SQLite database.

### Handling Network Interruptions and Background States

For Safari and Tauri environments, backgrounding the app kills HTTP streams. The `startVisibilityReconnect` method (lines 82‑107) registers a `visibilitychange` listener. If the page remains hidden for **more than 15 seconds**, it forces a `reconnect()` to restore the cloud stream.

```typescript
// src/db/powersync/database.ts
public startVisibilityReconnect(): void {
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) {
      this.hiddenTime = Date.now();
    } else if (this.hiddenTime && Date.now() - this.hiddenTime > 15000) {
      this.reconnect(); // Force reconnect after 15s hidden
    }
  });
}

```

This visibility-based reconnection is essential for reliable multi-device synchronization on iOS and desktop Tauri builds.

### Initial Synchronization Guard

Before the application reconciles default settings, it must ensure the first sync completes. The `waitForInitialSync()` method (lines 55‑88) listens for the `hasSynced` flag, resolving when the first full sync completes or after a 10‑second timeout.

The `useAppInitialization` hook calls this method before reconciling defaults, ensuring the UI sees cloud data immediately upon startup.

## Integration with React and Data Layer

Thunderbolt bridges PowerSync with React through custom hooks and a centralized table registry.

### Synced Tables and Query Invalidation

All tables replicated by PowerSync are declared in [`shared/powersync-tables.ts`](https://github.com/thunderbird/thunderbolt/blob/main/shared/powersync-tables.ts) (lines 8‑21). This single source of truth drives:

- **Backend sync rules** ([`config.yaml`](https://github.com/thunderbird/thunderbolt/blob/main/config.yaml))
- **Frontend Drizzle schema** ([`src/db/powersync/schema.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/db/powersync/schema.ts))
- **React Query invalidation** via `powersyncTableToQueryKeys`

When PowerSync detects table changes, it invalidates the corresponding React Query keys automatically, ensuring UI components re-render with fresh data.

### React Hooks for Synchronization

The application exposes several hooks to components:

- **`useSyncEnabledToggle`** – Manages the sync on/off state and persistence
- **`useSyncSetup`** – Orchestrates the multi-device onboarding wizard
- **`usePowerSyncStatus`** – Exposes connection status, upload/download progress, and sync completion via `useSyncExternalStore`
- **`useAppInitialization`** – Coordinates initial sync waiting before app readiness

## Code Examples

### Enable Sync with the Toggle Hook

```tsx
import { useSyncEnabledToggle } from '@/hooks/use-sync-enabled-toggle'

export function SettingsSyncToggle() {
  const {
    syncEnabled,
    syncSetupOpen,
    handleSyncToggle,
    handleSyncSetupComplete,
  } = useSyncEnabledToggle()

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={syncEnabled}
          onChange={e => handleSyncToggle(e.target.checked)}
        />
        Sync across devices
      </label>

      {/* Opens the setup wizard when needed */}
      {syncSetupOpen && (
        <SyncSetupModal onClose={() => setSyncSetupOpen(false)} onComplete={handleSyncSetupComplete} />
      )}
    </>
  )
}

```

*Key files:* [`src/hooks/use-sync-enabled-toggle.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/hooks/use-sync-enabled-toggle.ts) (lines 16‑84).

### Show Current PowerSync Status

```tsx
import { usePowerSyncStatus } from '@/hooks/use-powersync-status'

export function SyncStatusBadge() {
  const {
    isPowerSync,
    connectionStatus,
    isUploading,
    isDownloading,
    hasSynced,
  } = usePowerSyncStatus()

  if (!isPowerSync) return null

  const badge = connectionStatus === 'connected' && !isUploading && !isDownloading
    ? '✅ All synced'
    : connectionStatus === 'connecting'
      ? '⏳ Connecting…'
      : '⚡ Sync in progress'

  return <span title={badge}>{badge}</span>
}

```

*Key file:* [`src/hooks/use-powersync-status.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/hooks/use-powersync-status.ts) (lines 5‑97).

### Run the Multi-Device Onboarding Wizard

```tsx
import { useSyncSetup } from '@/hooks/use-sync-setup'

export function SyncSetupModal({ onClose, onComplete }) {
  const {
    step,
    error,
    isLoading,
    continueIntro,
    continueFirstDeviceSetup,
    submitRecoveryKey,
    confirmApproval,
    reset,
  } = useSyncSetup()

  // Minimal UI – real implementation lives in UI components; this shows the flow.
  return (
    <div>
      {step === 'intro' && (
        <button onClick={continueIntro} disabled={isLoading}>Start Sync Setup</button>
      )}

      {step === 'first-device-setup' && (
        <button onClick={continueFirstDeviceSetup} disabled={isLoading}>Generate Recovery Phrase</button>
      )}

      {step === 'recovery-key-entry' && (
        <form
          onSubmit={e => { e.preventDefault(); submitRecoveryKey().then(ok => ok && onComplete()) }}
        >
          <input placeholder="Enter 24‑word phrase" />
          <button type="submit" disabled={isLoading}>Recover</button>
        </form>
      )}

      {step === 'approval-waiting' && (
        <button onClick={confirmApproval} disabled={isLoading}>Check Approval</button>
      )}

      {error && <p className="error">{error}</p>}
    </div>
  )
}

```

*Key file:* [`src/hooks/use-sync-setup.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/hooks/use-sync-setup.ts) (lines 1‑254).

### Manually Force a Reconnect

```ts
import { reconnectSync } from '@/db/powersync'

await reconnectSync()   // Forces a disconnect + reconnect of the PowerSync instance

```

*Key function:* `reconnectSync` in [`src/db/powersync/database.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/db/powersync/database.ts) (lines 67‑73).

## Summary

- **PowerSync Engine**: Thunderbolt uses a singleton `PowerSyncDatabaseImpl` wrapping a SQLite-based sync engine with Web Workers for offline-first multi-device synchronization.
- **Device Onboarding**: New devices register via [`src/hooks/use-sync-setup.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/hooks/use-sync-setup.ts), which handles first-device recovery phrase generation or approval-based joining for additional devices, storing encrypted client keys in IndexedDB.
- **Sync Control**: The `useSyncEnabledToggle` hook persists user preferences to `localStorage` and manages PowerSync connection state, while `usePowerSyncStatus` exposes real-time connection metrics via `useSyncExternalStore`.
- **Platform Resilience**: Safari and Tauri builds use `OPFSCoopSyncVFS` with visibility-based reconnection logic to handle backgrounding and HTTP stream termination on iOS.
- **React Integration**: Centralized table definitions in [`shared/powersync-tables.ts`](https://github.com/thunderbird/thunderbolt/blob/main/shared/powersync-tables.ts) drive Drizzle schemas, backend sync rules, and automatic React Query invalidation when data changes.

## Frequently Asked Questions

### How does Thunderbolt handle the first device setup versus adding additional devices?

For the first device, `useSyncSetup` calls `completeFirstDeviceSetup` to generate a 24-word recovery phrase and encrypts the client key locally. For additional devices, the system detects an existing "canary" via `checkCanaryExists` and initiates an approval workflow where the user must authorize the new device from an already-trusted device using `checkApprovalAndUnwrap`.

### What happens when a user toggles synchronization off and on?

When disabled, `setSyncEnabled(false)` in [`src/db/powersync/database.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/db/powersync/database.ts) calls `disconnectFromSync` to sever the cloud connection while preserving local data. When re-enabled, `setSyncEnabled(true)` triggers `connectToSync`, re-establishing the PowerSync connection and resuming bidirectional replication from the SQLite database.

### How does Thunderbolt ensure data consistency when the app returns from background on iOS?

The `startVisibilityReconnect` method in [`src/db/powersync/database.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/db/powersync/database.ts) monitors `visibilitychange` events. If the app remains hidden for more than 15 seconds, it forces a `reconnect()` to restore HTTP streams killed by Safari or Tauri backgrounding, ensuring the sync engine resumes without user intervention.

### Where are the synchronized table schemas defined and how do they invalidate React Query caches?

All PowerSync tables are declared in [`shared/powersync-tables.ts`](https://github.com/thunderbird/thunderbolt/blob/main/shared/powersync-tables.ts). This single source of truth drives the Drizzle schema in [`src/db/powersync/schema.ts`](https://github.com/thunderbird/thunderbolt/blob/main/src/db/powersync/schema.ts), backend sync rules, and the `powersyncTableToQueryKeys` mapping. When PowerSync detects table changes, it invalidates the corresponding React Query keys automatically, ensuring UI components re-render with fresh data.