Development19 min read

Chrome Storage API: The Complete Guide

Everything about Chrome Storage API: local, sync, session, and managed storage. Covers quota limits, migration patterns, type safety, and real-world usage patterns.

C
CWS Kit Team
Share

The Chrome Storage API is how your extension remembers anything. User preferences, cached data, authentication tokens, feature flags, undo history — if it needs to survive a service worker restart, a browser session, or a sync across devices, it goes through chrome.storage. Yet most extension developers only ever use chrome.storage.local.get() and chrome.storage.local.set(), treating it like a slightly awkward localStorage with callbacks. That barely scratches the surface.

Chrome actually provides four distinct storage areas, each with different persistence guarantees, quota limits, and intended use cases. Choosing the wrong one leads to subtle bugs: data that vanishes on restart, sync conflicts that overwrite user preferences, quota errors that silently fail, or performance problems from writing too often. This guide covers all four areas in depth, with the type-safe patterns, migration strategies, and real-world techniques you need to build extensions that handle data correctly.

10 MB

Local Quota

Default; unlimited with unlimitedStorage permission

102,400 B

Sync Quota

Total across all sync keys per extension

10 MB

Session Quota

In-memory only; lost when service worker terminates

8,192 B

Sync Item Limit

Maximum size per individual sync storage key

The Four Storage Areas#

Every call to chrome.storage targets one of four areas: local, sync, session, or managed. They share the same API surface — get(), set(), remove(), clear(), and getBytesInUse() — but their behavior underneath differs significantly.

chrome.storage.local#

This is the workhorse. Data written to local persists across browser restarts, service worker terminations, and extension updates. It lives on disk, tied to the user's browser profile. The default quota is 10 MB, but you can request the unlimitedStorage permission in your manifest to remove that cap entirely.

Use local for anything that should survive long-term: user settings, application state, cached API responses, downloaded resources, and undo/redo history. If you are unsure which area to use, local is almost always the right answer.

// Writing structured data to local storage
interface UserSettings {
  theme: "light" | "dark" | "system";
  language: string;
  notifications: {
    enabled: boolean;
    frequency: "realtime" | "daily" | "weekly";
  };
  blockedSites: string[];
}
 
const defaults: UserSettings = {
  theme: "system",
  language: "en",
  notifications: { enabled: true, frequency: "realtime" },
  blockedSites: [],
};
 
async function saveSettings(partial: Partial<UserSettings>): Promise<void> {
  const { settings } = await chrome.storage.local.get({ settings: defaults });
  const merged = { ...settings, ...partial };
 
  // Deep merge for nested objects
  if (partial.notifications) {
    merged.notifications = { ...settings.notifications, ...partial.notifications };
  }
 
  await chrome.storage.local.set({ settings: merged });
}
 
async function getSettings(): Promise<UserSettings> {
  const { settings } = await chrome.storage.local.get({ settings: defaults });
  return settings;
}

One detail that trips up newcomers: the argument to get() is not just a list of keys. When you pass an object, the values in that object serve as defaults. If the key does not exist in storage, the default value is returned instead. This eliminates an entire class of null-check bugs.

chrome.storage.sync#

Sync storage propagates data across every Chrome instance where the user is signed in with the same Google account. When a user configures your extension on their work laptop, those settings appear on their home desktop within seconds. That sounds magical, but the constraints are strict: 102,400 bytes total, 8,192 bytes per item, 512 items maximum, and a write throttle of 120 operations per minute.

Use sync exclusively for small, user-facing preferences that should roam between devices. Do not store application state, cached data, or anything large. If you exceed the per-item limit, the write silently fails in some contexts or throws a quota error in others — both are bad.

// Sync storage for roaming user preferences
interface SyncPreferences {
  fontSize: number;
  colorScheme: string;
  shortcuts: Record<string, string>;
  lastSyncTimestamp: number;
}
 
async function saveSyncPreference<K extends keyof SyncPreferences>(
  key: K,
  value: SyncPreferences[K]
): Promise<void> {
  const storageKey = `pref_${key}`;
  const encoded = JSON.stringify(value);
 
  // Guard against the 8 KB per-item limit
  if (new Blob([encoded]).size > 8192) {
    console.error(`Sync preference "${key}" exceeds 8 KB limit (${encoded.length} chars)`);
    // Fall back to local storage for this key
    await chrome.storage.local.set({ [storageKey]: value });
    return;
  }
 
  await chrome.storage.sync.set({
    [storageKey]: value,
    lastSyncTimestamp: Date.now(),
  });
}

chrome.storage.session#

Session storage is the newest addition, introduced with Manifest V3. It lives entirely in memory and is never written to disk. Data persists across service worker restarts within the same browser session but is wiped when the browser closes. The quota is 10 MB.

This makes it ideal for sensitive tokens that should not touch disk, ephemeral caches, intermediate computation results, and any state that is expensive to recompute from scratch but does not need long-term persistence. By default, session storage is only accessible from the service worker. If you need content scripts or popups to access it, set chrome.storage.session.setAccessLevel to TRUSTED_AND_UNTRUSTED_CONTEXTS.

// Session storage for ephemeral auth and cache
async function initSessionStorage(): Promise<void> {
  // Allow popups and content scripts to read session storage
  await chrome.storage.session.setAccessLevel({
    accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS",
  });
}
 
interface SessionCache {
  authToken: string | null;
  tokenExpiry: number;
  apiResponseCache: Record<string, { data: unknown; cachedAt: number }>;
}
 
async function cacheApiResponse(endpoint: string, data: unknown): Promise<void> {
  const { apiResponseCache = {} } = await chrome.storage.session.get("apiResponseCache");
 
  // Evict stale entries (older than 5 minutes)
  const now = Date.now();
  const fresh: Record<string, { data: unknown; cachedAt: number }> = {};
  for (const [key, entry] of Object.entries(apiResponseCache)) {
    const cached = entry as { data: unknown; cachedAt: number };
    if (now - cached.cachedAt < 300_000) {
      fresh[key] = cached;
    }
  }
 
  fresh[endpoint] = { data, cachedAt: now };
  await chrome.storage.session.set({ apiResponseCache: fresh });
}
 
async function getCachedResponse(endpoint: string): Promise<unknown | null> {
  const { apiResponseCache = {} } = await chrome.storage.session.get("apiResponseCache");
  const entry = apiResponseCache[endpoint] as { data: unknown; cachedAt: number } | undefined;
 
  if (!entry || Date.now() - entry.cachedAt > 300_000) return null;
  return entry.data;
}

chrome.storage.managed#

Managed storage is read-only from the extension's perspective. It is configured by enterprise administrators through group policy (Windows), managed preferences (macOS), or Chrome Enterprise policies. Your extension reads from it, but cannot write to it.

This is relevant if you build extensions for enterprise deployment. Administrators can push default configuration, enforce settings, or disable features without the end user being able to override them. You declare the schema for managed storage in a JSON schema file referenced by your manifest.

Most independent extension developers never need managed storage. But if your extension targets organizations — think compliance tools, productivity suites, or internal dashboards — supporting it is a significant differentiator.

Do
  • Use local storage as your default for most data
  • Reserve sync storage for small user preferences that should roam across devices
  • Use session storage for auth tokens and sensitive ephemeral data
  • Pass default values as the argument to get() instead of null-checking afterward
  • Check getBytesInUse() before large writes to sync storage
  • Set session storage access level if popups or content scripts need it
Avoid
  • Store large blobs or base64 images in sync storage
  • Use session storage for data that must survive browser restart
  • Treat managed storage as writable — it is enterprise admin-controlled only
  • Assume set() calls always succeed — catch errors and handle quota limits
  • Store auth tokens in local storage when session storage is available
  • Use separate set() calls for related data — batch them into one call

Migrating From localStorage#

If you are coming from web development or migrating a MV2 extension, you might wonder why you cannot just use localStorage directly. In Manifest V3, the service worker has no access to localStorage or sessionStorage — those are DOM APIs, and the service worker has no DOM. Content scripts technically have access to localStorage, but it is the page's localStorage, not your extension's. Using it means your data is visible to the host page and gets cleared when the user clears site data for that domain.

The migration is straightforward in concept but requires attention to the async nature of Chrome's storage API. Every operation returns a Promise (or accepts a callback). There is no synchronous read.

// BEFORE: localStorage in MV2 background page
// const theme = localStorage.getItem("theme") || "light";
// localStorage.setItem("theme", "dark");
 
// AFTER: chrome.storage.local in MV3 service worker
async function getTheme(): Promise<string> {
  const { theme } = await chrome.storage.local.get({ theme: "light" });
  return theme;
}
 
async function setTheme(theme: string): Promise<void> {
  await chrome.storage.local.set({ theme });
}
 
// BEFORE: synchronous inline check
// if (localStorage.getItem("onboarded") === "true") { ... }
 
// AFTER: async pattern with early return
async function maybeShowOnboarding(): Promise<void> {
  const { onboarded } = await chrome.storage.local.get({ onboarded: false });
  if (onboarded) return;
 
  // Show onboarding UI
  await chrome.action.openPopup();
  await chrome.storage.local.set({ onboarded: true });
}

The biggest adjustment is not the API difference — it is that every storage read is now asynchronous. You cannot read a value inline in a conditional. Every access requires await, which means every calling function must be async, which propagates up the call chain. Plan your architecture around this from the start rather than retrofitting it later.

Listening for Changes With onChanged#

The chrome.storage.onChanged event fires whenever any value in any storage area changes. This is the primary mechanism for keeping your UI in sync with stored data. Popups, options pages, content scripts, and the service worker can all listen for changes independently.

The callback receives two arguments: an object describing what changed (with oldValue and newValue for each key), and a string identifying which storage area was modified.

// Type-safe onChanged listener
interface StorageSchema {
  settings: UserSettings;
  blocklist: string[];
  lastSync: number;
}
 
chrome.storage.onChanged.addListener(
  (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
    if (areaName !== "local") return;
 
    if (changes.settings) {
      const { oldValue, newValue } = changes.settings;
      const prev = oldValue as UserSettings | undefined;
      const next = newValue as UserSettings | undefined;
 
      if (next && prev?.theme !== next.theme) {
        applyTheme(next.theme);
      }
 
      if (next && prev?.notifications?.enabled !== next.notifications?.enabled) {
        next.notifications.enabled ? enableNotifications() : disableNotifications();
      }
    }
 
    if (changes.blocklist) {
      const sites = (changes.blocklist.newValue as string[]) ?? [];
      updateBlockingRules(sites);
    }
  }
);
 
// In a popup — react to changes made by the service worker
function setupReactiveUI(): void {
  chrome.storage.onChanged.addListener((changes, area) => {
    if (area !== "local") return;
 
    if (changes.settings?.newValue) {
      renderSettingsPanel(changes.settings.newValue as UserSettings);
    }
  });
}

A common mistake is registering onChanged listeners conditionally or inside setTimeout. In the service worker, all event listeners must be registered synchronously at the top level of your script. If you register a listener inside an async callback, the service worker might terminate and restart without re-registering it, and you will silently miss storage change events.

Building a Type-Safe Storage Wrapper#

Raw chrome.storage calls scatter type assertions throughout your codebase. Every get() returns Record<string, any>, and every set() accepts Record<string, any>. This is a recipe for typos, stale keys, and runtime errors that TypeScript cannot catch. A thin wrapper fixes all of this.

// A generic, type-safe storage wrapper
type StorageArea = "local" | "sync" | "session";
 
interface StorageMap {
  settings: UserSettings;
  blocklist: string[];
  stats: { totalBlocked: number; lastReset: number };
  authToken: string;
  onboardingStep: number;
}
 
function createStorage<T extends Record<string, unknown>>(
  area: StorageArea,
  defaults: T
) {
  const storage = chrome.storage[area];
 
  return {
    async get<K extends keyof T>(key: K): Promise<T[K]> {
      const result = await storage.get({ [key]: defaults[key] });
      return result[key as string] as T[K];
    },
 
    async getAll(): Promise<T> {
      const result = await storage.get(defaults);
      return result as T;
    },
 
    async set<K extends keyof T>(key: K, value: T[K]): Promise<void> {
      await storage.set({ [key as string]: value });
    },
 
    async setMany(partial: Partial<T>): Promise<void> {
      await storage.set(partial);
    },
 
    async remove<K extends keyof T>(...keys: K[]): Promise<void> {
      await storage.remove(keys as string[]);
    },
 
    async getBytesInUse(): Promise<number> {
      return storage.getBytesInUse(null);
    },
 
    onChange(
      callback: (changes: Partial<{ [K in keyof T]: { oldValue?: T[K]; newValue?: T[K] } }>) => void
    ): void {
      chrome.storage.onChanged.addListener((raw, areaName) => {
        if (areaName !== area) return;
        const typed = raw as Partial<{ [K in keyof T]: { oldValue?: T[K]; newValue?: T[K] } }>;
        callback(typed);
      });
    },
  };
}
 
// Instantiate typed stores
const localStore = createStorage<StorageMap>("local", {
  settings: { theme: "system", language: "en", notifications: { enabled: true, frequency: "realtime" }, blockedSites: [] },
  blocklist: [],
  stats: { totalBlocked: 0, lastReset: Date.now() },
  authToken: "",
  onboardingStep: 0,
});
 
const sessionStore = createStorage<Pick<StorageMap, "authToken">>("session", {
  authToken: "",
});
 
// Usage — fully typed, no casts needed
const settings = await localStore.get("settings");
// settings is UserSettings, not 'any'
 
await localStore.set("stats", { totalBlocked: 42, lastReset: Date.now() });
// TypeScript enforces the correct shape
 
localStore.onChange((changes) => {
  if (changes.settings?.newValue) {
    applyTheme(changes.settings.newValue.theme);
  }
});

This pattern costs you about 60 lines of code and eliminates an entire category of bugs. Every key is validated at compile time, every value has its correct type, and the onChange handler is scoped to only the storage area you care about.

Batching Writes for Performance#

Each chrome.storage.set() call is an I/O operation. In local and sync, it writes to disk (and for sync, potentially triggers a network request). Calling set() in a tight loop — say, updating a counter on every page navigation or saving state on every keystroke — creates unnecessary overhead and can hit the sync area's write throttle of 120 operations per minute.

The fix is batching. Collect writes over a short window and flush them in a single set() call.

// Debounced batch writer
class StorageBatcher {
  private pending: Record<string, unknown> = {};
  private timer: ReturnType<typeof setTimeout> | null = null;
  private readonly delay: number;
  private readonly storage: chrome.storage.StorageArea;
 
  constructor(area: StorageArea, delayMs: number = 500) {
    this.delay = delayMs;
    this.storage = chrome.storage[area];
  }
 
  queue(key: string, value: unknown): void {
    this.pending[key] = value;
 
    if (this.timer) clearTimeout(this.timer);
    this.timer = setTimeout(() => this.flush(), this.delay);
  }
 
  async flush(): Promise<void> {
    if (Object.keys(this.pending).length === 0) return;
 
    const batch = { ...this.pending };
    this.pending = {};
    this.timer = null;
 
    try {
      await this.storage.set(batch);
    } catch (err) {
      console.error("Batch write failed:", err);
      // Re-queue failed items
      this.pending = { ...batch, ...this.pending };
    }
  }
}
 
// Usage — writes are batched into a single set() every 500ms
const batcher = new StorageBatcher("local", 500);
 
function onUserAction(action: string): void {
  const stats = { lastAction: action, timestamp: Date.now() };
  batcher.queue("activityLog", stats);
  batcher.queue("actionCount", (currentCount ?? 0) + 1);
  // Both keys written in one set() call after 500ms of inactivity
}

For the sync area specifically, batching is not optional — it is mandatory for any extension that writes more than a couple of times per minute. Exceeding the write throttle causes set() to reject with QUOTA_BYTES_PER_ITEM or MAX_WRITE_OPERATIONS_PER_MINUTE errors, and those errors are easy to miss in production if you are not catching them.

Error Handling#

Chrome storage operations can fail. Quota exceeded, storage corruption, permission issues, or (in sync) network errors. The API communicates errors through chrome.runtime.lastError in callback mode, or by rejecting the Promise in async mode. You must handle both paths.

The most common error is QUOTA_BYTES_PER_ITEM in sync storage — writing an item larger than 8,192 bytes. The second most common is QUOTA_BYTES — exceeding the total storage area quota. Both are preventable if you check sizes before writing.

A defensive pattern for production:

async function safeStorageWrite(
  area: chrome.storage.StorageArea,
  data: Record<string, unknown>
): Promise<{ success: boolean; error?: string }> {
  try {
    await area.set(data);
    return { success: true };
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
 
    if (message.includes("QUOTA_BYTES")) {
      // Try to free space by clearing stale data
      const bytesUsed = await area.getBytesInUse(null);
      console.warn(`Storage quota issue. Bytes used: ${bytesUsed}. Error: ${message}`);
 
      // Attempt cleanup and retry
      await evictStaleEntries(area);
      try {
        await area.set(data);
        return { success: true };
      } catch (retryErr) {
        return { success: false, error: `Quota exceeded after cleanup: ${retryErr}` };
      }
    }
 
    return { success: false, error: message };
  }
}
 
async function evictStaleEntries(area: chrome.storage.StorageArea): Promise<void> {
  const all = await area.get(null);
  const keysToRemove: string[] = [];
 
  for (const [key, value] of Object.entries(all)) {
    // Remove entries older than 30 days if they have a timestamp
    if (value && typeof value === "object" && "cachedAt" in value) {
      const age = Date.now() - (value as { cachedAt: number }).cachedAt;
      if (age > 30 * 24 * 60 * 60 * 1000) {
        keysToRemove.push(key);
      }
    }
  }
 
  if (keysToRemove.length > 0) {
    await area.remove(keysToRemove);
  }
}

Checklist

  • Wrap all storage writes in try/catch blocks
  • Check chrome.runtime.lastError in callback-style code
  • Monitor getBytesInUse() for sync storage before large writes
  • Implement automatic eviction for cached or timestamped data
  • Log storage errors to your analytics or error tracking service
  • Handle the specific QUOTA_BYTES and QUOTA_BYTES_PER_ITEM errors
  • Test with storage quotas artificially reduced during development
  • Use session storage for data that does not need disk persistence
  • Batch writes to stay under sync's 120 operations-per-minute limit
  • Flush pending writes in onSuspend before the service worker terminates

Real-World Usage Patterns#

Pattern 1: Settings With Sync Fallback#

A robust approach for user preferences: try to read from sync first (for cross-device roaming), fall back to local if sync is empty or fails, and always write to both.

async function getPreference<T>(key: string, defaultValue: T): Promise<T> {
  // Try sync first for cross-device settings
  try {
    const syncResult = await chrome.storage.sync.get({ [key]: undefined });
    if (syncResult[key] !== undefined) return syncResult[key] as T;
  } catch {
    // Sync might be unavailable (offline, quota, etc.)
  }
 
  // Fall back to local
  const localResult = await chrome.storage.local.get({ [key]: defaultValue });
  return localResult[key] as T;
}
 
async function setPreference<T>(key: string, value: T): Promise<void> {
  // Always write to local (reliable)
  await chrome.storage.local.set({ [key]: value });
 
  // Best-effort write to sync (may fail due to quota or throttle)
  try {
    const encoded = JSON.stringify(value);
    if (new Blob([encoded]).size <= 8192) {
      await chrome.storage.sync.set({ [key]: value });
    }
  } catch {
    // Sync write failed — local is authoritative
  }
}

Pattern 2: Storage-Backed State Machine#

For extensions with complex workflows — multi-step onboarding, form wizards, or approval pipelines — store the state machine's current state in storage so it survives service worker restarts.

type OnboardingState = "welcome" | "permissions" | "configure" | "tutorial" | "complete";
 
interface OnboardingData {
  state: OnboardingState;
  startedAt: number;
  completedSteps: OnboardingState[];
}
 
async function advanceOnboarding(current: OnboardingState): Promise<OnboardingState> {
  const transitions: Record<OnboardingState, OnboardingState> = {
    welcome: "permissions",
    permissions: "configure",
    configure: "tutorial",
    tutorial: "complete",
    complete: "complete",
  };
 
  const next = transitions[current];
  const { onboarding } = await chrome.storage.local.get({
    onboarding: { state: "welcome", startedAt: Date.now(), completedSteps: [] },
  });
 
  const data = onboarding as OnboardingData;
  data.state = next;
  if (!data.completedSteps.includes(current)) {
    data.completedSteps.push(current);
  }
 
  await chrome.storage.local.set({ onboarding: data });
  return next;
}

Pattern 3: Cross-Context Communication via Storage#

Sometimes chrome.runtime.sendMessage is not the right tool. If you need a popup to react to changes made by a content script without the service worker acting as a relay, storage change events work as a pub/sub channel.

// Content script — publish a page analysis result
async function publishPageData(data: { title: string; wordCount: number }): Promise<void> {
  await chrome.storage.session.set({
    pageAnalysis: { ...data, url: location.href, timestamp: Date.now() },
  });
}
 
// Popup — subscribe to page analysis updates
chrome.storage.onChanged.addListener((changes, area) => {
  if (area !== "session" || !changes.pageAnalysis?.newValue) return;
 
  const analysis = changes.pageAnalysis.newValue;
  document.getElementById("title")!.textContent = analysis.title;
  document.getElementById("words")!.textContent = `${analysis.wordCount} words`;
});

This pattern works well for infrequent, small updates. For high-frequency communication (mouse positions, scroll events), use chrome.runtime.sendMessage or ports instead — storage writes for every frame of animation would be wasteful.

Quota Planning#

Before you ship, do the math on your storage usage. Here is a practical framework:

Local storage (10 MB default): Enough for most extensions. If you are storing images, large datasets, or offline caches, request unlimitedStorage in your manifest. This permission does not trigger any additional review scrutiny from Google — it is considered low-risk. But only request it if you actually need it; the default 10 MB is generous for settings and moderate caches.

Sync storage (102,400 bytes total): This is the constraint that bites. 100 KB sounds like a lot until you realize JSON serialization adds overhead. A settings object that looks small in code can balloon with long key names and nested structures. Measure with chrome.storage.sync.getBytesInUse(null) during development and set up alerts if you approach 80%.

Session storage (10 MB): Rarely a concern since it is in-memory and ephemeral. Just be aware that storing too much here increases your extension's memory footprint, and Chrome may terminate extensions that use excessive memory.

Managed storage: No practical quota concern — it is configured by enterprise admins and read-only from your extension.

For a deeper look at how storage interacts with service worker lifecycle and persistence strategies, see our Background Service Workers Deep Dive. If your extension involves content scripts that need to read and write storage, the Content Scripts: Patterns and Pitfalls guide covers the access patterns and isolation model in detail. And if you are publishing your extension for the first time, our launch checklist covers storage-related items alongside everything else you need before hitting publish.

Interactive tool

Listing Audit

Audit your Chrome Web Store listing for missing metadata, broken screenshots, and SEO issues before your next publish.

Open tool

Interactive tool

Manifest Validator

Validate your manifest.json for common errors, missing fields, and permission issues — including storage-related declarations.

Open tool

Summary#

Chrome's four storage areas each solve a different problem. Use local as your primary data store. Use sync sparingly for small preferences that should roam. Use session for sensitive tokens and ephemeral caches. Respect managed as the read-only enterprise configuration channel.

Wrap the raw API with type-safe accessors so TypeScript catches key typos and shape mismatches at compile time. Batch your writes to avoid quota throttling. Listen to onChanged for reactive UI updates instead of polling. Handle errors explicitly — quota limits, sync failures, and serialization issues are all recoverable if you plan for them.

The Chrome Storage API is not glamorous, but it is foundational. Every extension that handles user data well — that never loses settings, never corrupts state, never mysteriously forgets what the user configured — does so because someone took the time to get storage right. Now you have the patterns to do the same.

Continue reading

Related articles

View all posts