• Chrome Extensions
  • Manifest V3
  • Backend
  • Testing

Chrome extension storage limits: sync vs local vs session

Chrome extension storage limits for sync, local and session, the exact quota errors Chrome returns, how bytes are counted, and four fixes that work in MV3.

Author
Extenify Team
Published
Reading time
13 min read
Old apothecary jars with handwritten labels lined up on a shelf above a jar marked Leeches, in black and white

Chrome extension storage limits are small enough to hit and vague enough to surprise you. chrome.storage.sync holds about 100 KB, but a single 9 KB settings object fails long before that. chrome.storage.local holds 10 MB, unless you ask for more. chrome.storage.session lives in memory and disappears on every update. Pick the wrong area and you get a quota error in production, a user who loses their settings, or a review that says "it forgot everything after the update".

This guide compares sync vs local vs session in one table, shows how Chrome counts bytes, lists the exact error messages each limit produces, and gives four fixes for quota errors in a Manifest V3 extension. The limits come from the chrome.storage reference and Chromium's API schema. Every number and error string below was also measured in Chromium 156 on October 20, 2026, with a small test extension driven by Playwright.

Key takeaways

  • storage.sync: 102,400 bytes in total, 8,192 bytes per item, 512 items, 120 writes per minute and 1,800 writes per hour.
  • storage.local: 10 MB (5 MB in Chrome 113 and earlier). The unlimitedStorage permission removes the cap and shows no install warning.
  • storage.session: 10 MB in memory, cleared when the extension is disabled, reloaded or updated and when the browser restarts.
  • Size is the UTF-8 length of the JSON value plus the key. An accented letter costs 2 bytes, not 1.
  • A failed write rejects the promise with a message such as Resource::kQuotaBytesPerItem quota exceeded. Catch it, or the setting is silently lost.

Chrome extension storage limits at a glance

The table puts the official limits for each storage area side by side. The quota constants are also available at runtime, for example chrome.storage.sync.QUOTA_BYTES_PER_ITEM, and in our test they returned exactly these values.

storage.sync storage.local storage.session
Total size 102,400 bytes 10,485,760 bytes (10 MB) 10,485,760 bytes (10 MB)
Per item 8,192 bytes No separate limit No separate limit
Number of items 512 No limit No limit
Write rate 120 per minute, 1,800 per hour No limit No limit
Kept on disk Yes, and synced Yes No, memory only
Survives an update Yes Yes No
Content scripts can read it Yes, by default Yes, by default No, unless you change the access level
Raised by unlimitedStorage No Yes No

A few details from the reference are worth knowing before you choose:

  • Sync without sync. If the user has Chrome Sync turned off, storage.sync "behaves like storage.local", and when the browser is offline, "Chrome stores the data locally". The quotas still apply either way, so the 8 KB item limit is not something you can test your way around by signing out.
  • Local is cleared on uninstall. The reference says local data is "cleared when the extension is removed." Nothing in chrome.storage survives an uninstall, so a reinstall is a fresh start.
  • Session is short-lived by design. Session storage is cleared when "the extension is disabled, reloaded, updated, and when the browser restarts." Its limit was 1 MB in Chrome 111 and earlier.
  • localStorage is not an option in the background. Chrome's service worker migration guide states that window.localStorage "cannot be used in a service worker", which is why MV3 extensions lean on chrome.storage in the first place.

Sync vs local vs session: which one to use

The short version is that storage.sync is for a few small settings, storage.local is for everything you need to keep, and storage.session is for things you can afford to lose.

Use storage.sync for user preferences that should follow the user to another computer: a theme, a toggle, a short list of blocked sites. Keep each value well under 8 KB and write it only when the user changes something.

Use storage.local for data and state: caches, history, drafts, a generated user ID, the schema version your migrations rely on, anything that grows over time. It is also the right default when you are unsure, because it has about 100 times the room of sync even before unlimitedStorage.

Use storage.session for short-lived state that the service worker needs to remember between wake-ups: an in-progress job, a decrypted token, the last tab you acted on. Chrome terminates idle service workers, so global variables vanish; session storage is the in-memory place that outlives them. It is not exposed to content scripts by default, which makes it a reasonable home for a short-lived token. Call chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS' }) only if a content script genuinely needs it.

Use IndexedDB for large or structured data, such as thousands of records you want to query. With unlimitedStorage, the permissions list says the extension gets "an unlimited quota for chrome.storage.local, IndexedDB, Cache Storage, and Origin Private File System," and the same list shows no warning for that permission.

Never store secrets such as API keys in any of these areas. Anyone can unpack your extension and read its storage on their own machine; our guide to hiding an API key in a Chrome extension covers the backend proxy that solves that problem.

How Chrome counts bytes

The quota for sync and local is "measured by the JSON stringification of every value plus every key's length." In practice that means:

  • A string value costs its characters plus 2 for the quotes. In our test, { k: 'x'.repeat(8189) } used exactly 8,192 bytes and was accepted; one more character was rejected.
  • Bytes are UTF-8 bytes. 'é'.repeat(4094) was accepted at 8,191 bytes, while 'é'.repeat(4095) failed, so an accented string holds about half as many characters as an ASCII one. Emoji and CJK text cost 3 or 4 bytes per character.
  • Objects cost their JSON form. { obj: { a: 1, list: [1, 2] } } reported 23 bytes: 3 for the key and 20 for {"a":1,"list":[1,2]}.

You do not need to calculate this yourself. getBytesInUse() returns the figure Chrome will enforce:

const used = await chrome.storage.sync.getBytesInUse(null); // null = everything
const item = await chrome.storage.sync.getBytesInUse('settings');
console.log(`${used} of ${chrome.storage.sync.QUOTA_BYTES} bytes, settings uses ${item}`);

Serialization also changes some values. The schema warns that objects "will typically serialize to {}". In Chromium 156, a Date, a Map and a Set all came back as {}, and a key set to undefined was dropped. Store dates as ISO strings and collections as plain arrays or objects.

The quota exceeded errors, word for word

Every limit fails the same way: the write is rejected immediately, nothing is stored, and the promise rejects (or chrome.runtime.lastError is set if you use a callback). These are the messages we got from Chromium 156:

What you exceeded Error message
8 KB per item in sync Resource::kQuotaBytesPerItem quota exceeded
100 KB total in sync, or 10 MB in local Resource::kQuotaBytes quota exceeded
512 items in sync Resource::kMaxItems quota exceeded
120 sync writes in a minute This request exceeds the MAX_WRITE_OPERATIONS_PER_MINUTE quota.
1,800 sync writes in an hour This request exceeds the MAX_WRITE_OPERATIONS_PER_HOUR quota. (from the Chromium source)
10 MB in session Session storage quota bytes exceeded. Values were not stored.

The wording has changed between versions: the Chromium source up to at least version 130 formatted the first one as QUOTA_BYTES_PER_ITEM quota exceeded, so match on quota rather than on the full string. The write-rate limits count only writes that succeed: in our run, after 5 earlier successful writes, the 121st set() within the same minute was the one rejected. set(), remove() and clear() all count; reads do not.

The most common bug is not the limit itself but an unhandled rejection. A settings page that calls chrome.storage.sync.set(settings) without await and catch shows "Saved" while nothing was saved. Wrap writes in one helper:

// storage.js: one place that writes settings and reports quota problems.
export async function saveSettings(settings) {
  try {
    await chrome.storage.sync.set({ settings });
    return { ok: true };
  } catch (error) {
    if (/quota/i.test(error.message)) {
      // Too big or too frequent for sync: keep the user's change locally instead.
      await chrome.storage.local.set({ settings, settingsSyncFailed: true });
      return { ok: true, synced: false, reason: error.message };
    }
    throw error;
  }
}

Four ways to fix a quota error

1. Move large data from sync to local

If a value can grow (a list of rules, a history, a cache), it does not belong in sync. Move it once, in an update migration inside chrome.runtime.onInstalled, and read it from local afterwards:

chrome.runtime.onInstalled.addListener(async ({ reason }) => {
  if (reason !== 'update') return;
  const { rules } = await chrome.storage.sync.get('rules');
  if (rules === undefined) return; // already migrated, or never stored
  await chrome.storage.local.set({ rules });
  await chrome.storage.sync.remove('rules');
});

Write to local before removing from sync, so a service worker that stops halfway leaves a copy behind. Our guide to chrome.runtime.onInstalled covers versioned migrations in more depth.

2. Split one big value across several sync keys

When a value really must sync and is just over 8 KB, split it into chunks under the per-item limit. Measure in bytes, not characters, and stay below 512 items and 100 KB in total:

// large-sync.js: store one JSON value across several sync keys.
const CHUNK_BYTES = 6000; // base64 makes this 8,000 characters, under 8,192 with the key

const encode = (bytes) => btoa(String.fromCharCode(...bytes));
const decode = (text) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0));

export async function setLargeSync(key, value) {
  const bytes = new TextEncoder().encode(JSON.stringify(value));
  const items = {};
  let count = 0;
  for (let i = 0; i < bytes.length; i += CHUNK_BYTES, count++) {
    items[`${key}_${count}`] = encode(bytes.subarray(i, i + CHUNK_BYTES));
  }
  items[`${key}_count`] = count;

  const { [`${key}_count`]: oldCount = 0 } = await chrome.storage.sync.get(`${key}_count`);
  await chrome.storage.sync.set(items); // one write: all chunks or none
  const stale = [];
  for (let i = count; i < oldCount; i++) stale.push(`${key}_${i}`);
  if (stale.length) await chrome.storage.sync.remove(stale); // value shrank
}

export async function getLargeSync(key) {
  const { [`${key}_count`]: count } = await chrome.storage.sync.get(`${key}_count`);
  if (!count) return undefined;
  const keys = Array.from({ length: count }, (_, i) => `${key}_${i}`);
  const parts = await chrome.storage.sync.get(keys);
  const chunks = keys.map((k) => decode(parts[k]));
  const bytes = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
  let offset = 0;
  for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
  return JSON.parse(new TextDecoder().decode(bytes));
}

Splitting the UTF-8 bytes and base64-encoding each chunk keeps multi-byte characters from being cut in half, at the cost of a third more space: 6,000 bytes become 8,000 characters, which leaves room for the key and quotes. A single set() writes every chunk or fails as a whole, so readers never see half a value. The ceiling is still the 100 KB total, which leaves room for roughly 70 KB of original JSON. If that sounds like a lot of moving parts for a settings object, it is a sign the data belongs in local storage.

3. Throttle writes to sync

Sync allows 2 writes per second over a minute and 1 every 2 seconds over an hour. A slider or text field that saves on every input event burns through that in seconds. Debounce writes and save the final value:

let timer;
export function saveSettingSoon(key, value, delayMs = 1500) {
  clearTimeout(timer);
  timer = setTimeout(() => chrome.storage.sync.set({ [key]: value }), delayMs);
}

In a service worker, a setTimeout can be lost if the worker stops, so debounce in the page that collects the input (the popup or options page) rather than in the background.

4. Ask for unlimitedStorage, or use IndexedDB

If local storage is full, add "unlimitedStorage" to permissions. In our test, an 11 MB value failed with Resource::kQuotaBytes quota exceeded without the permission and was stored with it. The permission has no install warning, so adding it in an update should not trigger the re-approval prompt that permissions with warnings cause. For large collections you need to query, IndexedDB is the better tool regardless of the quota.

Read large stores in batches

chrome.storage.local.get(null) returns everything at once, which is fine for a few hundred keys and slow for hundreds of megabytes. Chrome 130 added getKeys(), which returns only the key names so you can read in batches. Chromium's source already contains a 25 MB limit for a single get() whose error says "Please use getKeys() and retrieve items in smaller batches." It sits behind a feature flag that was disabled by default when we checked in October 2026, but it shows the direction:

const keys = await chrome.storage.local.getKeys();
for (let i = 0; i < keys.length; i += 200) {
  const batch = await chrome.storage.local.get(keys.slice(i, i + 200));
  // process the batch
}

Firefox and other browsers

Firefox implements the same storage.sync limits: MDN lists 102,400 bytes in total, 8,192 bytes per item and 512 items, and says calls that exceed them "are rejected with an error." Three differences matter in practice: sync needs an add-on ID set in browser_specific_settings, Firefox syncs extension data every 10 minutes (or on Sync Now) rather than continuously, and Firefox for Android does not sync extension data at all. Edge is built on Chromium, so expect the same limits there. Whatever the browser, write code that treats sync as a best-effort copy of a few small settings, never as the only copy of user data.

Test your storage code before users do

Quota bugs rarely show up in development, where storage is nearly empty. They show up months later, on the power user with 600 rules. Two habits catch them early:

  • Test against real Chromium. Mocks of chrome.storage do not enforce quotas. Every error message in this post except the hourly one came from a real extension driven by Playwright, using the setup in our guide to testing a Chrome extension with Playwright. A test that writes a value of 8,193 bytes and expects a rejection takes a few lines.
  • Measure usage in production. Read getBytesInUse(null) once a day and report a coarse bucket (under 10%, 50%, 90% of the quota) with the rest of your telemetry. If you report usage, follow the privacy-safe approach in our post on measuring retention without over-collecting data.

Catch storage bugs in your reviews and ratings

When storage goes wrong, users rarely file a bug report. They leave a one-star review that says their settings vanished, or they uninstall. Extenify is a chrome extension analytics dashboard that records your users, rating, reviews and version on the Chrome Web Store, Edge Add-ons and Firefox Add-ons every day, with extension review monitoring that sends new reviews and rating drops to email, Slack, Discord, Telegram or Teams. After a release that touches storage, that is how you hear about a migration bug within a day instead of a week. Our checklist of what to watch after an extension release lists the other signals worth checking, and every plan starts with a 7-day free trial on the pricing page.

Frequently asked questions

What is the storage limit for a Chrome extension?

It depends on the area. chrome.storage.local holds 10 MB (5 MB in Chrome 113 and earlier) and can be unlimited with the unlimitedStorage permission. chrome.storage.sync holds 102,400 bytes, with at most 8,192 bytes per item and 512 items. chrome.storage.session holds 10 MB in memory.

What is the difference between chrome.storage.sync and chrome.storage.local?

Sync copies data to every Chrome browser where the user is signed in with sync turned on, and has tight limits: 100 KB in total, 8 KB per item and 120 writes per minute. Local stays on one device, holds 10 MB or more, and has no write-rate limit. Use sync for a few small preferences and local for everything else.

Why do I get "QUOTA_BYTES_PER_ITEM quota exceeded"?

One value in chrome.storage.sync is larger than 8,192 bytes, counted as the UTF-8 length of its JSON plus the key. Current Chrome versions phrase it as Resource::kQuotaBytesPerItem quota exceeded. Move the value to storage.local, split it into chunks, or store less.

Does chrome.storage.sync work if the user is not signed in?

Yes. With sync turned off, storage.sync behaves like storage.local: the data stays on that device. The sync quotas still apply.

Is chrome.storage data deleted when the extension is uninstalled?

Yes. Local data is cleared when the extension is removed, and session data does not even survive an update. If users need their data back after a reinstall, offer an export or store it on your own backend.

Can I use localStorage in a Manifest V3 extension?

Not in the service worker, where window.localStorage does not exist. Extension pages such as the popup can still use it, but it is synchronous, limited and not shared with the service worker, so chrome.storage.local is the better choice.

Summary

Chrome extension storage limits come down to three areas. Sync is tiny and synced: 100 KB in total, 8 KB per item, 512 items and 120 writes a minute, which suits a few settings. Local is 10 MB, or unlimited with a permission that carries no warning, and should hold almost everything. Session is 10 MB of memory that disappears on every update and restart. Chrome counts the UTF-8 bytes of the JSON value plus the key, rejects anything over the limit with a quota error, and stores nothing when it does. Catch those errors, keep growing data out of sync, debounce sync writes, and test against real Chromium so the first person to hit a quota is not a paying user.

Image credits

Put every store in one dashboard tonight.

Sign in with Google, paste one store link, and your unified view is live in under a minute. Then 7 days of Pro free, no credit card required, and you never touch your app's code.

Start free trial