Chrome Storage API Advanced Patterns: sync, local, session and managed

30 min read

Chrome Storage API Advanced Patterns: sync, local, session and managed

Chrome Storage API Advanced Patterns: sync, local, session and managed

The chrome.storage API is the primary mechanism for persisting data in Chrome extensions. Unlike localStorage or IndexedDB, it is purpose-built for extensions — it works across all extension contexts (service workers, popups, content scripts, options pages), supports automatic synchronization across devices, and provides change listeners for reactive programming patterns.

This guide covers all four storage areas — sync, local, session, and managed — with a focus on advanced patterns, quota management, data migration strategies, and real-time synchronization techniques that you will need for production-quality extensions.


Storage Areas Overview

Chrome provides four distinct storage areas, each designed for different use cases:

Storage Area Capacity Persists Syncs Access
chrome.storage.local 10 MB (or unlimited with permission) Yes, across browser restarts No Extension only
chrome.storage.sync 100 KB total, 8 KB per item Yes, across browser restarts Yes, across devices Extension only
chrome.storage.session 10 MB No, cleared on browser close No Extension only
chrome.storage.managed Varies Yes Via enterprise policy Read-only for extension

Permission Requirements

Add the "storage" permission to your manifest:

{
  "manifest_version": 3,
  "permissions": ["storage"]
}

For unlimited local storage capacity, also add:

{
  "permissions": ["storage", "unlimitedStorage"]
}

chrome.storage.local — Persistent Local Storage

Local storage is the workhorse for most extension data. It provides generous storage limits, fast access, and persistence across browser restarts.

Basic CRUD Operations

// SET — Store data
await chrome.storage.local.set({
  userProfile: {
    name: 'Alice',
    theme: 'dark',
    lastLogin: Date.now()
  },
  cache: {
    articles: [],
    fetchedAt: null
  }
});

// GET — Retrieve data
const { userProfile } = await chrome.storage.local.get('userProfile');
console.log(userProfile.name); // 'Alice'

// Get multiple keys
const data = await chrome.storage.local.get(['userProfile', 'cache']);

// Get all data
const everything = await chrome.storage.local.get(null);

// Get with defaults
const result = await chrome.storage.local.get({
  userProfile: { name: 'Guest', theme: 'light' },
  visitCount: 0
});
// If 'visitCount' does not exist, it returns 0

// REMOVE — Delete specific keys
await chrome.storage.local.remove('cache');
await chrome.storage.local.remove(['cache', 'tempData']);

// CLEAR — Delete everything
await chrome.storage.local.clear();

Data Structure Design

Design your storage keys thoughtfully to avoid hitting per-key size limits and to make partial updates efficient:

// Bad: one giant object that must be read/written entirely
await chrome.storage.local.set({
  appState: {
    settings: { /* ... */ },
    userData: { /* ... */ },
    cache: { /* large dataset */ },
    history: [ /* thousands of entries */ ]
  }
});

// Good: separate keys for independent data
await chrome.storage.local.set({
  'settings': { theme: 'dark', fontSize: 14 },
  'user:profile': { name: 'Alice', email: 'alice@example.com' },
  'cache:articles': [ /* ... */ ],
  'cache:timestamp': Date.now(),
  'history:recent': [ /* last 100 entries */ ]
});

Using namespaced keys (like cache:articles) keeps data modular. You can update one piece without reading and rewriting the entire state.


chrome.storage.sync — Cross-Device Synchronization

Sync storage automatically propagates data across all Chrome instances where the user is signed in. It is ideal for user preferences and small configuration data.

Quota Constraints

Sync storage has strict limits you must respect:

Limit Value
Total storage 102,400 bytes (100 KB)
Per-item size 8,192 bytes (8 KB)
Maximum items 512
Write operations per hour 1,800
Write operations per minute 120
// Check how much space you are using
const bytesInUse = await chrome.storage.sync.getBytesInUse(null);
console.log(`Using ${bytesInUse} of 102,400 bytes`);

// Check specific keys
const keyBytes = await chrome.storage.sync.getBytesInUse(['settings']);

Sync-Safe Data Patterns

Because of the strict quotas, you need to be intentional about what goes into sync storage:

// Store only user preferences in sync
await chrome.storage.sync.set({
  preferences: {
    theme: 'dark',
    fontSize: 14,
    language: 'en',
    notifications: true,
    shortcutKey: 'Ctrl+Shift+E'
  }
});

// Store large or frequently-changing data in local
await chrome.storage.local.set({
  cache: { /* large data */ },
  analytics: { /* usage stats */ },
  history: [ /* browsing history */ ]
});

Handling Sync Conflicts

When the same key is modified on different devices simultaneously, Chrome uses a last-write-wins strategy. To handle this gracefully, include timestamps:

async function saveSyncedSetting(key, value) {
  const update = {
    [key]: {
      value,
      updatedAt: Date.now(),
      device: await getDeviceId()
    }
  };
  await chrome.storage.sync.set(update);
}

async function getDeviceId() {
  let { deviceId } = await chrome.storage.local.get('deviceId');
  if (!deviceId) {
    deviceId = crypto.randomUUID();
    await chrome.storage.local.set({ deviceId });
  }
  return deviceId;
}

Chunking Large Data for Sync

If you need to sync data that exceeds the 8 KB per-item limit, split it into chunks:

async function syncLargeData(key, data) {
  const serialized = JSON.stringify(data);
  const chunkSize = 7000; // Leave room for metadata
  const chunks = [];

  for (let i = 0; i < serialized.length; i += chunkSize) {
    chunks.push(serialized.slice(i, i + chunkSize));
  }

  const storageObj = {
    [`${key}:meta`]: {
      chunks: chunks.length,
      totalSize: serialized.length,
      updatedAt: Date.now()
    }
  };

  chunks.forEach((chunk, index) => {
    storageObj[`${key}:chunk:${index}`] = chunk;
  });

  await chrome.storage.sync.set(storageObj);
}

async function readLargeData(key) {
  const meta = await chrome.storage.sync.get(`${key}:meta`);
  const metaData = meta[`${key}:meta`];

  if (!metaData) return null;

  const chunkKeys = Array.from(
    { length: metaData.chunks },
    (_, i) => `${key}:chunk:${i}`
  );

  const chunks = await chrome.storage.sync.get(chunkKeys);
  const serialized = chunkKeys.map(k => chunks[k]).join('');

  return JSON.parse(serialized);
}

chrome.storage.session — Ephemeral Per-Session Storage

Session storage was introduced in Manifest V3 to replace the in-memory state that MV2 background pages could maintain. Data in session storage is cleared when the browser closes but persists across service worker restarts within the same browser session.

Use Cases

Session storage is perfect for:

  • Temporary state that should not persist across browser restarts
  • Cached API responses that expire naturally when the browser closes
  • In-progress form data or workflow state
  • Authentication tokens that should not be stored permanently
// Store session-specific state
await chrome.storage.session.set({
  activeWorkflow: {
    step: 2,
    startedAt: Date.now(),
    data: { /* intermediate results */ }
  },
  cachedApiResponse: {
    data: apiResult,
    expiresAt: Date.now() + 3600000
  }
});

// Retrieve session state — returns {} if browser was restarted
const { activeWorkflow } = await chrome.storage.session.get('activeWorkflow');
if (activeWorkflow) {
  resumeWorkflow(activeWorkflow);
} else {
  startNewWorkflow();
}

Exposing Session Storage to Content Scripts

By default, session storage is only accessible from the extension’s service worker, popup, and options page. To make it accessible from content scripts:

// background.js — call this once (e.g., in onInstalled)
chrome.storage.session.setAccessLevel({
  accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS'
});

After this call, content scripts can read and write session storage directly.

Service Worker State Management

Session storage solves the biggest pain point of MV3 service workers — losing in-memory state when the worker is terminated:

// background.js
class StateManager {
  constructor() {
    this.state = null;
  }

  async get() {
    if (!this.state) {
      const stored = await chrome.storage.session.get('serviceWorkerState');
      this.state = stored.serviceWorkerState || this.getDefaults();
    }
    return this.state;
  }

  async update(partial) {
    this.state = { ...(await this.get()), ...partial };
    await chrome.storage.session.set({
      serviceWorkerState: this.state
    });
    return this.state;
  }

  getDefaults() {
    return {
      activeTabs: [],
      processingQueue: [],
      lastSync: null,
      errorCount: 0
    };
  }
}

const state = new StateManager();

// Use it in your message handlers
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'ADD_TO_QUEUE') {
    state.get().then(async (current) => {
      current.processingQueue.push(message.item);
      await state.update({ processingQueue: current.processingQueue });
      sendResponse({ queued: true });
    });
    return true;
  }
});

chrome.storage.managed — Enterprise Policy Storage

Managed storage allows IT administrators to configure your extension via enterprise policies. The extension can only read this data — it is written by administrators through Chrome Enterprise management tools.

Schema Definition

Define a JSON schema for your managed storage in a separate file:

// managed_schema.json
{
  "type": "object",
  "properties": {
    "serverUrl": {
      "type": "string",
      "description": "API server URL",
      "title": "Server URL"
    },
    "enableLogging": {
      "type": "boolean",
      "description": "Enable debug logging",
      "title": "Enable Logging"
    },
    "blockedDomains": {
      "type": "array",
      "items": { "type": "string" },
      "description": "Domains to block",
      "title": "Blocked Domains"
    },
    "maxCacheSize": {
      "type": "integer",
      "minimum": 10,
      "maximum": 1000,
      "description": "Maximum cache size in MB",
      "title": "Max Cache Size"
    }
  }
}

Reference it in your manifest:

{
  "storage": {
    "managed_schema": "managed_schema.json"
  }
}

Reading Managed Settings

async function getEffectiveConfig() {
  // Read managed (admin) settings
  const managed = await chrome.storage.managed.get(null).catch(() => ({}));

  // Read user settings
  const user = await chrome.storage.sync.get(null);

  // Managed settings override user settings
  return {
    serverUrl: managed.serverUrl || user.serverUrl || 'https://api.default.com',
    enableLogging: managed.enableLogging ?? user.enableLogging ?? false,
    blockedDomains: [
      ...(managed.blockedDomains || []),
      ...(user.blockedDomains || [])
    ],
    maxCacheSize: managed.maxCacheSize || user.maxCacheSize || 100
  };
}

Listening for Changes with onChanged

The chrome.storage.onChanged event fires whenever any storage area is modified, enabling reactive programming patterns.

Basic Change Listener

chrome.storage.onChanged.addListener((changes, areaName) => {
  console.log(`Storage area "${areaName}" changed:`);

  for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
    console.log(`  ${key}:`, oldValue, '->', newValue);
  }
});

Area-Specific Listeners

You can also listen to specific storage areas:

// Only listen to sync changes
chrome.storage.sync.onChanged.addListener((changes) => {
  if (changes.preferences) {
    applyPreferences(changes.preferences.newValue);
  }
});

// Only listen to local changes
chrome.storage.local.onChanged.addListener((changes) => {
  if (changes.cache) {
    updateUI(changes.cache.newValue);
  }
});

Reactive UI Updates in Popup

Use onChanged to keep your popup UI synchronized with background state:

// popup.js
async function initPopup() {
  // Load initial state
  const { stats } = await chrome.storage.local.get('stats');
  renderStats(stats);

  // Listen for real-time updates
  chrome.storage.local.onChanged.addListener((changes) => {
    if (changes.stats) {
      renderStats(changes.stats.newValue);
    }
  });
}

function renderStats(stats) {
  if (!stats) return;
  document.getElementById('pageCount').textContent = stats.pagesAnalyzed || 0;
  document.getElementById('issueCount').textContent = stats.issuesFound || 0;
  document.getElementById('lastScan').textContent =
    stats.lastScanTime ? new Date(stats.lastScanTime).toLocaleString() : 'Never';
}

initPopup();

Cross-Tab Content Script Synchronization

Keep content scripts on different tabs in sync using storage change events:

// content-script.js
chrome.storage.sync.onChanged.addListener((changes) => {
  if (changes.preferences) {
    const prefs = changes.preferences.newValue;
    document.documentElement.style.setProperty(
      '--ext-font-size', `${prefs.fontSize}px`
    );
    document.documentElement.classList.toggle(
      'ext-dark-mode', prefs.theme === 'dark'
    );
  }
});

Advanced Patterns

Storage Wrapper with Type Safety

Build a typed storage wrapper for cleaner code:

// storage.js
class TypedStorage {
  constructor(area = 'local') {
    this.storage = chrome.storage[area];
  }

  async get(key, defaultValue = undefined) {
    const result = await this.storage.get({ [key]: defaultValue });
    return result[key];
  }

  async set(key, value) {
    await this.storage.set({ [key]: value });
  }

  async remove(key) {
    await this.storage.remove(key);
  }

  async update(key, updater) {
    const current = await this.get(key);
    const updated = updater(current);
    await this.set(key, updated);
    return updated;
  }

  onChange(key, callback) {
    const listener = (changes) => {
      if (changes[key]) {
        callback(changes[key].newValue, changes[key].oldValue);
      }
    };
    this.storage.onChanged.addListener(listener);
    return () => this.storage.onChanged.removeListener(listener);
  }
}

// Usage
const settings = new TypedStorage('sync');
const cache = new TypedStorage('local');

// Read with default
const theme = await settings.get('theme', 'light');

// Update atomically
await cache.update('visitCount', (count) => (count || 0) + 1);

// Watch for changes
const unsubscribe = settings.onChange('theme', (newTheme, oldTheme) => {
  console.log(`Theme changed from ${oldTheme} to ${newTheme}`);
  applyTheme(newTheme);
});

Data Migration Between Versions

When your extension updates, you may need to migrate stored data to a new format:

// background.js
const CURRENT_SCHEMA_VERSION = 3;

chrome.runtime.onInstalled.addListener(async (details) => {
  if (details.reason === 'update') {
    await migrateStorage();
  }
});

async function migrateStorage() {
  const { schemaVersion } = await chrome.storage.local.get({
    schemaVersion: 1
  });

  if (schemaVersion >= CURRENT_SCHEMA_VERSION) return;

  console.log(`Migrating from schema v${schemaVersion} to v${CURRENT_SCHEMA_VERSION}`);

  const migrations = {
    1: migrateV1ToV2,
    2: migrateV2ToV3
  };

  for (let v = schemaVersion; v < CURRENT_SCHEMA_VERSION; v++) {
    if (migrations[v]) {
      console.log(`Running migration v${v} -> v${v + 1}`);
      await migrations[v]();
    }
  }

  await chrome.storage.local.set({ schemaVersion: CURRENT_SCHEMA_VERSION });
  console.log('Migration complete');
}

async function migrateV1ToV2() {
  // V1 stored settings as individual keys
  // V2 consolidates them into a single object
  const oldKeys = ['darkMode', 'fontSize', 'autoSave'];
  const oldData = await chrome.storage.sync.get(oldKeys);

  if (Object.keys(oldData).length > 0) {
    await chrome.storage.sync.set({
      settings: {
        theme: oldData.darkMode ? 'dark' : 'light',
        fontSize: oldData.fontSize || 14,
        autoSave: oldData.autoSave ?? true
      }
    });
    await chrome.storage.sync.remove(oldKeys);
  }
}

async function migrateV2ToV3() {
  // V3 adds new fields with defaults
  const { settings } = await chrome.storage.sync.get('settings');
  if (settings) {
    await chrome.storage.sync.set({
      settings: {
        ...settings,
        language: settings.language || 'en',
        notifications: settings.notifications ?? true
      }
    });
  }
}

Quota-Aware Storage with Automatic Eviction

For local storage caches, implement automatic eviction when approaching quota limits:

class CacheStorage {
  constructor(prefix = 'cache', maxSize = 5 * 1024 * 1024) {
    this.prefix = prefix;
    this.maxSize = maxSize;
  }

  async put(key, data) {
    const cacheKey = `${this.prefix}:${key}`;
    const entry = {
      data,
      cachedAt: Date.now(),
      size: JSON.stringify(data).length
    };

    // Check if we need to evict
    await this.evictIfNeeded(entry.size);

    await chrome.storage.local.set({ [cacheKey]: entry });
    await this.updateIndex(key, entry.size, entry.cachedAt);
  }

  async get(key, maxAge = Infinity) {
    const cacheKey = `${this.prefix}:${key}`;
    const result = await chrome.storage.local.get(cacheKey);
    const entry = result[cacheKey];

    if (!entry) return null;

    // Check expiry
    if (Date.now() - entry.cachedAt > maxAge) {
      await this.remove(key);
      return null;
    }

    return entry.data;
  }

  async remove(key) {
    const cacheKey = `${this.prefix}:${key}`;
    await chrome.storage.local.remove(cacheKey);
    await this.removeFromIndex(key);
  }

  async evictIfNeeded(incomingSize) {
    const { cacheIndex = [] } = await chrome.storage.local.get('cacheIndex');
    const totalSize = cacheIndex.reduce((sum, e) => sum + e.size, 0);

    if (totalSize + incomingSize <= this.maxSize) return;

    // Sort by age (oldest first) and evict until we have room
    const sorted = [...cacheIndex].sort((a, b) => a.cachedAt - b.cachedAt);
    let freed = 0;

    for (const entry of sorted) {
      if (totalSize - freed + incomingSize <= this.maxSize) break;
      await this.remove(entry.key);
      freed += entry.size;
    }
  }

  async updateIndex(key, size, cachedAt) {
    const { cacheIndex = [] } = await chrome.storage.local.get('cacheIndex');
    const filtered = cacheIndex.filter(e => e.key !== key);
    filtered.push({ key, size, cachedAt });
    await chrome.storage.local.set({ cacheIndex: filtered });
  }

  async removeFromIndex(key) {
    const { cacheIndex = [] } = await chrome.storage.local.get('cacheIndex');
    await chrome.storage.local.set({
      cacheIndex: cacheIndex.filter(e => e.key !== key)
    });
  }
}

// Usage
const cache = new CacheStorage('apiCache', 2 * 1024 * 1024); // 2MB limit

// Cache API responses
const articles = await cache.get('articles', 300000); // 5 min max age
if (!articles) {
  const fresh = await fetchArticles();
  await cache.put('articles', fresh);
}

Batching Writes to Avoid Quota Throttling

Sync storage has write-rate limits. Batch rapid updates to avoid hitting them:

class BatchWriter {
  constructor(storage, flushIntervalMs = 1000) {
    this.storage = storage;
    this.pending = {};
    this.flushInterval = flushIntervalMs;
    this.timer = null;
  }

  set(key, value) {
    this.pending[key] = value;
    this.scheduleFlush();
  }

  scheduleFlush() {
    if (this.timer) return;
    this.timer = setTimeout(() => this.flush(), this.flushInterval);
  }

  async flush() {
    this.timer = null;
    if (Object.keys(this.pending).length === 0) return;

    const batch = { ...this.pending };
    this.pending = {};

    try {
      await this.storage.set(batch);
      console.log('Flushed batch:', Object.keys(batch));
    } catch (error) {
      // Re-queue failed writes
      this.pending = { ...batch, ...this.pending };
      console.error('Batch write failed:', error);
      this.scheduleFlush();
    }
  }
}

// Usage
const writer = new BatchWriter(chrome.storage.sync, 2000);

// These rapid updates are batched into a single write
writer.set('lastVisited', Date.now());
writer.set('visitCount', 42);
writer.set('currentPage', '/dashboard');

Choosing the Right Storage Area

Use this decision guide to select the appropriate storage area:

Question Answer Storage Area
Should it sync across devices? Yes sync
Is it larger than 100KB? Yes local
Should it clear when browser closes? Yes session
Is it set by IT administrators? Yes managed
Is it user preferences (small data)? Yes sync
Is it cached data or large datasets? Yes local
Is it temporary workflow state? Yes session

For many extensions, the right approach is using multiple storage areas together:

// Sync: user preferences (small, synced)
await chrome.storage.sync.set({ theme: 'dark', language: 'en' });

// Local: cached data and large datasets
await chrome.storage.local.set({ articleCache: largeDataset });

// Session: temporary state for current browser session
await chrome.storage.session.set({ currentTaskId: 'abc123' });


Summary

The Chrome Storage API is far more than a simple key-value store. With four specialized storage areas, change listeners, and cross-context availability, it provides a complete data layer for Chrome extensions.

Key takeaways:

  1. Use sync for small user preferences that should follow the user across devices. Respect the strict quotas.
  2. Use local for large datasets, caches, and data that does not need to sync. Request unlimitedStorage if you need more than 10 MB.
  3. Use session to replace in-memory state from MV2 background pages. Data survives service worker restarts but clears when the browser closes.
  4. Use managed to support enterprise deployment with administrator-configured settings.
  5. Listen to onChanged for reactive patterns — keep your UI in sync with storage state without polling.
  6. Implement data migrations in your onInstalled handler to handle schema changes across extension updates.
  7. Batch writes to sync storage to avoid hitting rate limits.

By combining these storage areas and patterns, you can build extensions with robust, performant, and user-friendly data management.

No previous article
No next article