Skip to content

chrome.storage.local vs chrome.storage.sync: quotas, limits, and when to use each

5 min readModerok team

chrome.storage.local vs chrome.storage.sync in a Chrome extension: exact quotas, the 8 KB per-item cap, write throttling, and which to use for what.

If you are deciding between chrome.storage.local and chrome.storage.sync in a Chrome extension, the short answer is this: use local for almost everything, and reach for sync only for a small amount of data that genuinely needs to follow the user across their signed-in browsers. local gives you roughly 10 MB and fast, unthrottled writes. sync gives you 100 KB total, a hard 8 KB per-item cap, and rate-limited writes, in exchange for replicating that data through the user's Google (or Firefox) account. Pick the wrong one and you will hit QUOTA_BYTES_PER_ITEM quota exceeded at runtime, or silently lose writes to throttling.

Below are the exact numbers, the failure modes that catch people out, and a concrete pattern for splitting your data between the two.

The exact quotas

Both areas share the same API surface (get, set, remove, clear, getBytesInUse), but their limits are very different. The constants are exposed on the storage areas themselves, so you can read them at runtime.

chrome.storage.local:

  • QUOTA_BYTES is 10 MB (10,485,760 bytes). It was 5 MB in Chrome 113 and earlier and was raised to 10 MB in Chrome 114.
  • Requesting the "unlimitedStorage" permission removes the local quota entirely. It does not raise the sync quota.

chrome.storage.sync:

  • QUOTA_BYTES is 100 KB (102,400 bytes) total across all keys.
  • QUOTA_BYTES_PER_ITEM is 8 KB (8,192 bytes) for any single key/value pair.
  • MAX_ITEMS is 512 keys.
  • MAX_WRITE_OPERATIONS_PER_HOUR is 1,800.
  • MAX_WRITE_OPERATIONS_PER_MINUTE is 120 (two per second).

Sizes are measured by the JSON stringification of each value plus the length of its key, not the raw object in memory. A short key with a chunky value is what usually blows the 8 KB per-item limit.

Firefox uses the same sync numbers on purpose. Its storage.sync limits (100 KB total, 8 KB per item, 512 items) were chosen to line up with Chrome, so a design that fits inside Chrome's sync budget will fit Firefox's too. The main Firefox caveat is that storage.sync only actually syncs when the user is signed in to a Firefox Account; otherwise it behaves like local storage on that install.

Why the 8 KB per-item cap bites

The limit developers trip over most is not the 100 KB total, it is QUOTA_BYTES_PER_ITEM. You can have plenty of total budget left and still have a single set() rejected because one value serialized to more than 8 KB. A settings object that started small grows a history array or a base64 thumbnail, crosses 8,192 bytes, and every write of that key now fails.

The failure is not silent if you check for it, but it is easy to miss. In a callback-style call the error shows up on chrome.runtime.lastError; with the promise form the promise rejects. If you never read either, the write just does not happen:

chrome.storage.sync.set({ prefs: bigObject }, () => {
  if (chrome.runtime.lastError) {
    console.warn("sync write failed:", chrome.runtime.lastError.message);
    // fall back to local, or shrink/split the value
  }
});

Always inspect chrome.runtime.lastError inside the callback. That single habit surfaces quota errors, and it is the same discipline that keeps other MV3 callback APIs from failing silently. We wrote about it in catching chrome.runtime.lastError in MV3 service workers.

If you need one logical value that is bigger than 8 KB, split it across multiple keys yourself and reassemble on read. Just watch the 512-item ceiling and the total 100 KB budget while you do it.

Write throttling is the other trap

sync caps you at 120 writes per minute and 1,800 per hour. Those are generous for saving a preference when a user toggles a checkbox, and far too small for anything that writes on a loop. If your service worker updates a sync key on every event, every navigation, or on a timer, you will burn through the per-minute budget, and subsequent set() calls fail until the window resets.

local has no comparable write-rate limit, which is another reason high-frequency state belongs there. A good rule: if a value changes more than a few times a minute, it is local data, not sync data.

A concrete split: keep identity tiny, keep the rest local

Here is how Moderok's SDK handles this, because it is a useful template. The SDK stores everything it needs in chrome.storage.local: its config, the anonymous user id, and any events still waiting to send. That is the bulk of the data and it changes often, so local is the natural home. (Persisting the queue in chrome.storage.local is also what lets events survive a service worker restart, which we covered in why your MV3 service worker keeps stopping.)

It then mirrors one tiny slice into chrome.storage.sync: just the anonymous userId and a lastPingDate string, under a single key. That subset is a few dozen bytes, nowhere near the 8 KB item cap, and it is written roughly once per bootstrap rather than on every event, which keeps it far inside the 120-writes-per-minute limit.

Why mirror the id into sync at all? On managed and shared devices, notably school Chromebooks, the local profile can be wiped between sessions. When Chrome Sync is on, a value in storage.sync survives that wipe because it lives on the user's account, so the extension recovers the same anonymous id instead of minting a new one and looking like a brand-new install. The bulky, frequently-changing data stays in local where quotas and write rates are not a concern.

The shape of the decision generalizes:

  • local: config, caches, queued work, anything larger than a couple of KB, anything that changes frequently. Do not sync it.
  • sync: small, stable, user-facing settings and identifiers that should travel across the user's devices. Keep each item well under 8 KB and write it rarely.

Practical checklist

  • Default to local. Only promote a value to sync when cross-device persistence is a real requirement, not a nice-to-have.
  • Keep every sync item small. Measure with chrome.storage.sync.getBytesInUse(key) and stay clear of 8,192 bytes per item and 102,400 total.
  • Never write sync on a hot path. Batch or debounce so you stay under 120 writes per minute.
  • Check chrome.runtime.lastError (or handle the rejected promise) on every sync.set, and fall back to local when a write is rejected.
  • Add only the storage permission to your manifest. It covers both local and sync, and it does not trigger a scary install prompt the way host permissions do.
  • Remember sync is best-effort: it only replicates when the user is signed in and syncing. Never treat it as a guaranteed cross-device write.

Getting this split right is invisible when it works and painful when it does not: dropped settings, phantom installs, and quota errors that only show up on some users' machines. Moderok's SDK ships this pattern by default, storing its data in local and mirroring only an anonymous id into sync, so you get resilient install counts without managing any of it. If you want that out of the box, the SDK is a few kilobytes and needs only the storage permission.