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

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 – 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 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, src/db/powersync/schema.ts, and 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).

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

// 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.

// 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 (lines 8‑21). This single source of truth drives:

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

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 (lines 16‑84).

Show Current PowerSync Status

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 (lines 5‑97).

Run the Multi-Device Onboarding Wizard

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 (lines 1‑254).

Manually Force a Reconnect

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

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

Key function: reconnectSync in 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, 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 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 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 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. This single source of truth drives the Drizzle schema in 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.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →