Chrome Extension Data Sync — Best Practices
32 min readData Synchronization Patterns
Overview
Chrome extensions that store user data face a deceptively hard problem: keeping that data consistent across devices, respecting quota limits, and recovering gracefully when things go wrong. The chrome.storage.sync API handles the transport layer, but conflict resolution, delta tracking, and migration are your responsibility. This guide covers eight practical patterns for reliable data synchronization.
Storage Area Comparison
| Property | storage.local |
storage.sync |
storage.session |
|---|---|---|---|
| Max total size | 10 MB | 100 KB | 10 MB |
| Max per item | No limit | 8,192 bytes | No limit |
| Max items | No limit | 512 | No limit |
| Write operations/hour | No limit | 1,800 | No limit |
| Syncs across devices | No | Yes | No |
| Persists on restart | Yes | Yes | No |
| Available in | All contexts | All contexts | All contexts (MV3) |
Pattern 1: Cross-Device Settings with chrome.storage.sync
The simplest sync pattern — store user preferences that follow them across devices:
// types.ts
interface UserSettings {
theme: "light" | "dark" | "system";
fontSize: number;
notifications: boolean;
blockedSites: string[];
lastUpdated: number;
}
const DEFAULT_SETTINGS: UserSettings = {
theme: "system",
fontSize: 14,
notifications: true,
blockedSites: [],
lastUpdated: 0,
};
// settings.ts
async function getSettings(): Promise<UserSettings> {
const stored = await chrome.storage.sync.get("settings");
return { ...DEFAULT_SETTINGS, ...stored.settings };
}
async function updateSettings(
partial: Partial<UserSettings>
): Promise<UserSettings> {
const current = await getSettings();
const updated: UserSettings = {
...current,
...partial,
lastUpdated: Date.now(),
};
await chrome.storage.sync.set({ settings: updated });
return updated;
}
// Usage in options page
const saveButton = document.getElementById("save")!;
saveButton.addEventListener("click", async () => {
const theme = (document.getElementById("theme") as HTMLSelectElement).value;
await updateSettings({ theme: theme as UserSettings["theme"] });
});
Gotcha: Sync Is Eventually Consistent
Changes written on device A may take seconds to minutes to appear on device B. Never assume immediate consistency — always treat storage.sync as an eventually-consistent store.
Pattern 2: Conflict Resolution Strategies
When two devices edit the same data before sync completes, you need a strategy.
Last-Write-Wins (LWW)
The simplest approach — timestamp every change, keep the newest:
// conflict-lww.ts
interface Timestamped<T> {
value: T;
updatedAt: number;
deviceId: string;
}
function resolveConflictLWW<T>(
local: Timestamped<T>,
remote: Timestamped<T>
): Timestamped<T> {
if (remote.updatedAt > local.updatedAt) {
return remote;
}
if (remote.updatedAt === local.updatedAt) {
// Deterministic tiebreaker: compare device IDs lexicographically
return remote.deviceId > local.deviceId ? remote : local;
}
return local;
}
// Generate a stable device ID on first install
async function getDeviceId(): Promise<string> {
const { deviceId } = await chrome.storage.local.get("deviceId");
if (deviceId) return deviceId;
const id = crypto.randomUUID();
await chrome.storage.local.set({ deviceId: id });
return id;
}
Field-Level Merge
For complex objects, merge at the field level instead of replacing the whole object:
// conflict-merge.ts
interface MergeableSettings {
[key: string]: {
value: unknown;
updatedAt: number;
};
}
function mergeFields(
local: MergeableSettings,
remote: MergeableSettings
): MergeableSettings {
const result: MergeableSettings = { ...local };
for (const [key, remoteField] of Object.entries(remote)) {
const localField = local[key];
if (!localField || remoteField.updatedAt > localField.updatedAt) {
result[key] = remoteField;
}
}
return result;
}
// Example: two devices change different fields simultaneously
// Device A: changes theme at t=100
// Device B: changes fontSize at t=101
// Result: both changes are preserved
Set Union for Collections
For arrays like blocklists, union is often safer than replacement:
// conflict-union.ts
function mergeBlocklists(local: string[], remote: string[]): string[] {
return [...new Set([...local, ...remote])];
}
// For sets with add/remove, track operations instead of state
interface SetOperation {
action: "add" | "remove";
item: string;
timestamp: number;
}
function applyOperations(operations: SetOperation[]): Set<string> {
// Sort by timestamp, then apply in order
const sorted = [...operations].sort((a, b) => a.timestamp - b.timestamp);
const result = new Set<string>();
for (const op of sorted) {
if (op.action === "add") result.add(op.item);
else result.delete(op.item);
}
return result;
}
Pattern 3: Optimistic UI Updates with storage.onChanged
Update the UI immediately on local write, then reconcile when the real sync fires:
// optimistic-ui.ts
class SettingsStore {
private listeners = new Set<(settings: UserSettings) => void>();
private cache: UserSettings | null = null;
constructor() {
// Listen for changes from other devices or other extension contexts
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "sync" || !changes.settings) return;
const remote = changes.settings.newValue as UserSettings;
this.cache = remote;
this.notifyListeners(remote);
});
}
subscribe(listener: (settings: UserSettings) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notifyListeners(settings: UserSettings) {
for (const listener of this.listeners) {
listener(settings);
}
}
async get(): Promise<UserSettings> {
if (this.cache) return this.cache;
this.cache = await getSettings();
return this.cache;
}
async update(partial: Partial<UserSettings>): Promise<void> {
// 1. Optimistically update cache and notify UI
const current = await this.get();
const optimistic = { ...current, ...partial, lastUpdated: Date.now() };
this.cache = optimistic;
this.notifyListeners(optimistic);
// 2. Persist — if this fails, the onChanged listener will correct the UI
try {
await chrome.storage.sync.set({ settings: optimistic });
} catch (error) {
// Revert to what storage actually has
this.cache = null;
const actual = await this.get();
this.notifyListeners(actual);
throw error;
}
}
}
// Usage in popup
const store = new SettingsStore();
store.subscribe((settings) => {
document.getElementById("theme-label")!.textContent = settings.theme;
});
Handling Cross-Context Updates
The storage.onChanged event fires in every extension context (popup, options, content scripts, service worker). This means you get free cross-context reactivity:
// content.ts — automatically reacts to settings changed in the popup
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "sync") return;
if (changes.settings?.newValue?.theme) {
applyTheme(changes.settings.newValue.theme);
}
});
Pattern 4: Quota Management
Sync storage is small. You must plan for it.
Checking Usage Before Write
// quota.ts
interface QuotaInfo {
totalBytesUsed: number;
totalBytesAvailable: number;
itemCount: number;
maxItems: number;
bytesPerItem: number;
}
async function getQuotaInfo(): Promise<QuotaInfo> {
const bytesInUse = await chrome.storage.sync.getBytesInUse(null);
return {
totalBytesUsed: bytesInUse,
totalBytesAvailable: chrome.storage.sync.QUOTA_BYTES - bytesInUse,
itemCount: Object.keys(await chrome.storage.sync.get(null)).length,
maxItems: chrome.storage.sync.MAX_ITEMS, // 512
bytesPerItem: chrome.storage.sync.QUOTA_BYTES_PER_ITEM, // 8,192
};
}
async function safeSync(key: string, value: unknown): Promise<boolean> {
const json = JSON.stringify({ [key]: value });
const byteSize = new Blob([json]).size;
// Check per-item limit
if (byteSize > chrome.storage.sync.QUOTA_BYTES_PER_ITEM) {
console.error(
`Item "${key}" is ${byteSize} bytes, exceeds ${chrome.storage.sync.QUOTA_BYTES_PER_ITEM} limit`
);
return false;
}
// Check total quota
const quota = await getQuotaInfo();
if (byteSize > quota.totalBytesAvailable) {
console.error(
`Not enough quota: need ${byteSize}, have ${quota.totalBytesAvailable}`
);
return false;
}
await chrome.storage.sync.set({ [key]: value });
return true;
}
Splitting Large Data Across Keys
When a single object exceeds 8 KB, split it across multiple keys:
// chunked-storage.ts
const CHUNK_SIZE = 7_500; // Leave headroom under 8,192
async function setChunked(prefix: string, data: unknown): Promise<void> {
const json = JSON.stringify(data);
const chunks: string[] = [];
for (let i = 0; i < json.length; i += CHUNK_SIZE) {
chunks.push(json.slice(i, i + CHUNK_SIZE));
}
const items: Record<string, unknown> = {
[`${prefix}_meta`]: { chunkCount: chunks.length, updatedAt: Date.now() },
};
for (let i = 0; i < chunks.length; i++) {
items[`${prefix}_${i}`] = chunks[i];
}
await chrome.storage.sync.set(items);
}
async function getChunked<T>(prefix: string): Promise<T | null> {
const metaResult = await chrome.storage.sync.get(`${prefix}_meta`);
const meta = metaResult[`${prefix}_meta`];
if (!meta) return null;
const keys = Array.from(
{ length: meta.chunkCount },
(_, i) => `${prefix}_${i}`
);
const chunks = await chrome.storage.sync.get(keys);
const json = keys.map((k) => chunks[k] ?? "").join("");
return JSON.parse(json) as T;
}
async function removeChunked(prefix: string): Promise<void> {
const metaResult = await chrome.storage.sync.get(`${prefix}_meta`);
const meta = metaResult[`${prefix}_meta`];
if (!meta) return;
const keys = [
`${prefix}_meta`,
...Array.from({ length: meta.chunkCount }, (_, i) => `${prefix}_${i}`),
];
await chrome.storage.sync.remove(keys);
}
Pattern 5: Background Sync with External APIs
Sync extension data with your own server using the service worker:
// background.ts
interface SyncState {
lastSyncTimestamp: number;
pendingChanges: Record<string, unknown>[];
syncInProgress: boolean;
}
const syncState: SyncState = {
lastSyncTimestamp: 0,
pendingChanges: [],
syncInProgress: false,
};
// Queue changes during offline or error states
function queueChange(change: Record<string, unknown>): void {
syncState.pendingChanges.push(change);
scheduleSyncWithAlarm();
}
function scheduleSyncWithAlarm(): void {
chrome.alarms.create("background-sync", {
delayInMinutes: 1,
periodInMinutes: 15,
});
}
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name !== "background-sync") return;
await performSync();
});
async function performSync(): Promise<void> {
if (syncState.syncInProgress) return;
syncState.syncInProgress = true;
try {
const changes = [...syncState.pendingChanges];
if (changes.length === 0) return;
const response = await fetch("https://api.example.com/sync", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${await getAuthToken()}`,
},
body: JSON.stringify({
changes,
lastSync: syncState.lastSyncTimestamp,
}),
});
if (!response.ok) {
throw new Error(`Sync failed: ${response.status}`);
}
const serverChanges = await response.json();
// Apply server changes to local storage
if (serverChanges.updates) {
await chrome.storage.local.set(serverChanges.updates);
}
// Clear synced changes from the queue
syncState.pendingChanges = syncState.pendingChanges.slice(changes.length);
syncState.lastSyncTimestamp = Date.now();
await chrome.storage.local.set({
lastSyncTimestamp: syncState.lastSyncTimestamp,
});
} catch (error) {
console.error("Background sync failed:", error);
// Changes remain in the queue for next attempt
} finally {
syncState.syncInProgress = false;
}
}
async function getAuthToken(): Promise<string> {
// Use chrome.identity for OAuth flows
return new Promise((resolve, reject) => {
chrome.identity.getAuthToken({ interactive: false }, (token) => {
if (chrome.runtime.lastError || !token) {
reject(chrome.runtime.lastError);
} else {
resolve(token);
}
});
});
}
Exponential Backoff on Failure
// backoff.ts
async function syncWithBackoff(maxRetries = 5): Promise<void> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
await performSync();
return;
} catch {
const delay = Math.min(1000 * 2 ** attempt, 60_000);
const jitter = Math.random() * 1000;
await new Promise((resolve) => setTimeout(resolve, delay + jitter));
}
}
console.error("Sync failed after maximum retries");
}
Pattern 6: Delta Sync
Only sync what changed, not the entire dataset:
// delta-sync.ts
interface DeltaEntry {
key: string;
value: unknown;
timestamp: number;
deleted?: boolean;
}
class DeltaTracker {
private storageKey = "delta_log";
async recordChange(key: string, value: unknown): Promise<void> {
const log = await this.getLog();
log.push({ key, value, timestamp: Date.now() });
await chrome.storage.local.set({ [this.storageKey]: log });
}
async recordDeletion(key: string): Promise<void> {
const log = await this.getLog();
log.push({ key, value: null, timestamp: Date.now(), deleted: true });
await chrome.storage.local.set({ [this.storageKey]: log });
}
async getChangesSince(timestamp: number): Promise<DeltaEntry[]> {
const log = await this.getLog();
return log.filter((entry) => entry.timestamp > timestamp);
}
async clearProcessedEntries(upToTimestamp: number): Promise<void> {
const log = await this.getLog();
const remaining = log.filter((entry) => entry.timestamp > upToTimestamp);
await chrome.storage.local.set({ [this.storageKey]: remaining });
}
private async getLog(): Promise<DeltaEntry[]> {
const result = await chrome.storage.local.get(this.storageKey);
return result[this.storageKey] ?? [];
}
}
// Usage: wrap your storage writes with delta tracking
const deltaTracker = new DeltaTracker();
async function updateSetting(key: string, value: unknown): Promise<void> {
await chrome.storage.sync.set({ [key]: value });
await deltaTracker.recordChange(key, value);
}
// During server sync, only send deltas
async function syncDeltasToServer(lastSyncTime: number): Promise<void> {
const deltas = await deltaTracker.getChangesSince(lastSyncTime);
if (deltas.length === 0) return;
const response = await fetch("https://api.example.com/sync/delta", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deltas }),
});
if (response.ok) {
const latestTimestamp = Math.max(...deltas.map((d) => d.timestamp));
await deltaTracker.clearProcessedEntries(latestTimestamp);
}
}
Pattern 7: Import/Export User Data as JSON Backup
Let users take their data with them:
// export.ts
async function exportAllData(): Promise<string> {
const [syncData, localData] = await Promise.all([
chrome.storage.sync.get(null),
chrome.storage.local.get(null),
]);
const exportPayload = {
version: chrome.runtime.getManifest().version,
exportedAt: new Date().toISOString(),
sync: syncData,
local: localData,
};
return JSON.stringify(exportPayload, null, 2);
}
function downloadJson(data: string, filename: string): void {
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
// Use chrome.downloads for a clean save dialog
chrome.downloads.download({
url,
filename,
saveAs: true,
});
// Revoke after a delay to allow the download to start
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
// Trigger from options page
document.getElementById("export-btn")!.addEventListener("click", async () => {
const json = await exportAllData();
downloadJson(json, `extension-backup-${Date.now()}.json`);
});
// import.ts
interface ImportPayload {
version: string;
exportedAt: string;
sync: Record<string, unknown>;
local: Record<string, unknown>;
}
async function importData(file: File): Promise<{ success: boolean; message: string }> {
try {
const text = await file.text();
const payload: ImportPayload = JSON.parse(text);
// Validate structure
if (!payload.version || !payload.exportedAt) {
return { success: false, message: "Invalid backup file format." };
}
// Validate version compatibility
const currentVersion = chrome.runtime.getManifest().version;
if (!isCompatibleVersion(payload.version, currentVersion)) {
return {
success: false,
message: `Backup from v${payload.version} is not compatible with v${currentVersion}.`,
};
}
// Validate sync data fits within quota
const syncJson = JSON.stringify(payload.sync);
if (new Blob([syncJson]).size > chrome.storage.sync.QUOTA_BYTES) {
return { success: false, message: "Sync data exceeds storage quota." };
}
// Write data
await chrome.storage.sync.clear();
await chrome.storage.sync.set(payload.sync);
await chrome.storage.local.set(payload.local);
return { success: true, message: "Data imported successfully." };
} catch {
return { success: false, message: "Failed to parse backup file." };
}
}
function isCompatibleVersion(backup: string, current: string): boolean {
const [backupMajor] = backup.split(".").map(Number);
const [currentMajor] = current.split(".").map(Number);
return backupMajor === currentMajor; // Same major version = compatible
}
// File input handler
document.getElementById("import-input")!.addEventListener("change", async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const result = await importData(file);
const status = document.getElementById("import-status")!;
status.textContent = result.message;
status.className = result.success ? "success" : "error";
});
Pattern 8: Migration Between Storage Areas
Move data from storage.local to storage.sync (or vice versa) during upgrades:
// migration.ts
interface MigrationConfig {
from: "local" | "sync";
to: "local" | "sync";
keys: string[];
transform?: (key: string, value: unknown) => unknown;
deleteAfterMigration: boolean;
}
async function migrateStorageArea(config: MigrationConfig): Promise<void> {
const source =
config.from === "local" ? chrome.storage.local : chrome.storage.sync;
const target =
config.to === "local" ? chrome.storage.local : chrome.storage.sync;
const data = await source.get(config.keys);
// Apply optional transformations
const transformed: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
transformed[key] = config.transform ? config.transform(key, value) : value;
}
// Validate size if migrating to sync
if (config.to === "sync") {
for (const [key, value] of Object.entries(transformed)) {
const size = new Blob([JSON.stringify({ [key]: value })]).size;
if (size > chrome.storage.sync.QUOTA_BYTES_PER_ITEM) {
throw new Error(
`Key "${key}" (${size} bytes) exceeds sync per-item limit`
);
}
}
}
await target.set(transformed);
if (config.deleteAfterMigration) {
await source.remove(config.keys);
}
}
Version-Based Migration Runner
// migration-runner.ts
interface Migration {
version: number;
name: string;
run: () => Promise<void>;
}
const migrations: Migration[] = [
{
version: 1,
name: "Move settings from local to sync",
run: async () => {
await migrateStorageArea({
from: "local",
to: "sync",
keys: ["settings"],
deleteAfterMigration: true,
});
},
},
{
version: 2,
name: "Compress blocklist for sync quota",
run: async () => {
const { blocklist } = await chrome.storage.sync.get("blocklist");
if (Array.isArray(blocklist) && blocklist.length > 200) {
// Keep only the 200 most recent entries in sync, archive rest locally
const sorted = [...blocklist].sort();
await chrome.storage.sync.set({ blocklist: sorted.slice(-200) });
await chrome.storage.local.set({ blocklist_archive: sorted });
}
},
},
];
async function runMigrations(): Promise<void> {
const { migrationVersion = 0 } = await chrome.storage.local.get(
"migrationVersion"
);
const pending = migrations.filter((m) => m.version > migrationVersion);
for (const migration of pending) {
console.log(`Running migration ${migration.version}: ${migration.name}`);
try {
await migration.run();
await chrome.storage.local.set({ migrationVersion: migration.version });
} catch (error) {
console.error(`Migration ${migration.version} failed:`, error);
break; // Stop on first failure
}
}
}
// Run on install and update
chrome.runtime.onInstalled.addListener(async (details) => {
if (details.reason === "install" || details.reason === "update") {
await runMigrations();
}
});
Summary
| Pattern | When To Use |
|---|---|
storage.sync settings |
Simple preferences that follow the user across devices |
| Conflict resolution (LWW / merge) | Multiple devices may edit the same data concurrently |
Optimistic UI + onChanged |
UI must feel instant and stay reactive across contexts |
| Quota management + chunking | Data approaches sync storage limits (100 KB total, 8 KB/item) |
| Background sync with alarms | Extension data must sync with an external server |
| Delta sync | Large datasets where full sync is wasteful |
| Import/export JSON | Users need data portability and backup capability |
| Storage area migration | Upgrading schema or moving data between local and sync |
Sync storage is convenient but constrained. Always validate against quota limits before writing, assume eventual consistency between devices, and give users an escape hatch with import/export. For anything beyond simple settings, consider storage.local as the primary store with selective sync of critical data via storage.sync.
-e
—
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.