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:
registerThisDevice– Creates a device record on the backend and returns whether the device is already trusted.checkCanaryExists– Determines if any device already exists in the account.- If no canary exists,
completeFirstDeviceSetupgenerates a 24-word recovery phrase and encrypts the client key (CK) locally insrc/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:
- Backend sync rules (
config.yaml) - Frontend Drizzle schema (
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 persistenceuseSyncSetup– Orchestrates the multi-device onboarding wizardusePowerSyncStatus– Exposes connection status, upload/download progress, and sync completion viauseSyncExternalStoreuseAppInitialization– 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
PowerSyncDatabaseImplwrapping 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
useSyncEnabledTogglehook persists user preferences tolocalStorageand manages PowerSync connection state, whileusePowerSyncStatusexposes real-time connection metrics viauseSyncExternalStore. - Platform Resilience: Safari and Tauri builds use
OPFSCoopSyncVFSwith visibility-based reconnection logic to handle backgrounding and HTTP stream termination on iOS. - React Integration: Centralized table definitions in
shared/powersync-tables.tsdrive 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →