"QUOTA_BYTES quota exceeded": Chrome Extension Storage Limits Explained
5 min readModerok team
Why chrome.storage.local throws QUOTA_BYTES quota exceeded, the real byte limits for local, sync, and session, and how to stay under them.
If a chrome.storage.local.set() call is failing with QUOTA_BYTES quota exceeded, you have written more data than the area allows and the write was rejected whole. The short answer: chrome.storage.local holds 10 MB by default (as of Chrome 114), the write that trips the limit fails immediately and sets chrome.runtime.lastError, and the fix is either to store less, request the unlimitedStorage permission, or move oversized data somewhere that is not a quota-limited key-value store.
This post covers the exact limits for each storage area, how Chrome measures your usage, what the errors mean, and the patterns that keep a Manifest V3 extension under quota.
What "QUOTA_BYTES quota exceeded" actually means
Every chrome.storage area has a byte budget. When a set() would push the area over that budget, Chrome does not partially write and truncate; it rejects the entire operation and reports an error. With a callback you read it from chrome.runtime.lastError; with the promise form you get a rejection:
chrome.storage.local.set({ bigThing: hugeObject }, () => {
if (chrome.runtime.lastError) {
// e.g. "QUOTA_BYTES quota exceeded"
console.error("storage write failed:", chrome.runtime.lastError.message);
}
});
If you use await chrome.storage.local.set(...) and never wrap it in a try/catch, the rejection becomes an unhandled promise rejection in the service worker. In an MV3 background worker with no DevTools attached, that failure is easy to miss entirely, which is a close cousin of the silent-callback problem we covered in catching chrome.runtime.lastError before it silently breaks your extension.
How Chrome measures your usage
For local and sync, the number Chrome checks against QUOTA_BYTES is not the size of your JavaScript objects in memory. It is the size of the JSON serialization of every value, plus the length of every key. Roughly, for each item Chrome counts JSON.stringify(value).length + key.length and sums across all items in the area. (The in-memory session area is the exception: it estimates the dynamically allocated memory of each key and value instead of stringifying, so the numbers below are not directly comparable.)
Two consequences follow for the disk-backed areas. First, deeply nested objects and long strings cost exactly what they serialize to, so a base64 blob or a big cached API response is measured at full size. Second, you can check your own footprint at runtime with getBytesInUse, which returns the same measure Chrome enforces against:
const used = await chrome.storage.local.getBytesInUse(null); // null = whole area
console.log(`${used} of ${chrome.storage.local.QUOTA_BYTES} bytes used`);
getBytesInUse accepts a key or array of keys if you want to find which entry is the offender, or null for the whole area. The constants (QUOTA_BYTES, and for sync QUOTA_BYTES_PER_ITEM and MAX_ITEMS) are readable straight off the area object, so you never have to hardcode them.
The real limits for local, sync, and session
The three areas have very different budgets, and picking the wrong one is the usual root cause of a quota error.
chrome.storage.local
QUOTA_BYTES is 10,485,760 bytes (10 MB) as of Chrome 114. Before that release the limit was about 5 MB, so older "5 MB" advice you find online is stale. There is no per-item limit on local, so a single 9 MB value is fine as long as the area total stays under budget. Requesting the unlimitedStorage permission removes the QUOTA_BYTES cap entirely, at the cost of declaring that permission in your manifest.
chrome.storage.sync
Sync is small and rate-limited because Chrome replicates it across a user's signed-in devices. The limits:
QUOTA_BYTES: 102,400 bytes (100 KB) total across the whole areaQUOTA_BYTES_PER_ITEM: 8,192 bytes (8 KB) for any single item (key + JSON value)MAX_ITEMS: 512 keysMAX_WRITE_OPERATIONS_PER_HOUR: 1,800MAX_WRITE_OPERATIONS_PER_MINUTE: 120
Exceed the per-item cap and you get QUOTA_BYTES_PER_ITEM quota exceeded; write too fast and you get a MAX_WRITE_OPERATIONS_PER_MINUTE error. Sync is for small, portable settings, not for data.
chrome.storage.session
Session storage is in-memory: it is not persisted to disk and is cleared when the browser shuts down. Its QUOTA_BYTES is 10,485,760 bytes (10 MB) (raised from roughly 1 MB in Chrome 112). It is the right home for values you want to survive a service worker restart within one browser session but do not need on disk, such as a decoded token or a request cache.
Fixes that actually work
Store less, or store it compressed. If you are caching an API response "just in case," ask whether you need all of it. Trimming fields or storing a derived summary is cheaper than storing the raw payload. For genuinely large text, compression before set() (for example with CompressionStream) can buy a lot of headroom, since quota is measured on the serialized bytes.
Split large data across items and evict. If you are accumulating records, cap how many you keep and drop the oldest, so the area cannot grow without bound. A ring-buffer of the last N items is almost always what you want instead of an ever-growing array under one key.
Do not put big data in sync. A surprising amount of "quota exceeded" pain is an extension trying to sync something that belongs in local. Keep sync for a handful of small settings and identity-style values, and put everything else in local.
Reach for unlimitedStorage only when you truly need it. It is a legitimate escape hatch for extensions that cache large assets offline, but it is a declared permission and it puts the storage-management burden on you. Do not add it just to paper over a leak you could fix by evicting old data.
Consider IndexedDB for large, structured data. chrome.storage is a key-value store tuned for settings and small state. If you are storing megabytes of structured records and querying them, IndexedDB (available in the service worker) is the better tool and is not bound by QUOTA_BYTES.
How Moderok stays under quota
Anything that writes to chrome.storage from the background has to respect these limits, and analytics is a common offender because it batches events. The Moderok SDK is built to keep its footprint small and bounded.
Retryable failures are persisted to chrome.storage.local so they can be retried with backoff after a service worker restart (the MV3 termination model is covered in why your Chrome extension service worker keeps stopping). To keep that storage from growing without limit, the SDK caps the persisted queue to the most recent 500 events and drops any single event larger than 8 KB before it is ever enqueued, so its share of the 10 MB local budget stays predictable.
For the tiny bit of profile state that benefits from following a signed-in browser across devices (the random profile id and the last daily-ping date), the SDK writes only that subset to chrome.storage.sync, precisely because sync is limited to 8 KB per item and 100 KB total and rate-limited to 120 writes per minute. Everything else stays in local. That split is the same advice this post gives: small, portable values in sync, real data in local, and hard bounds so neither area can overflow.
If you want background analytics that already handles storage quotas, batching, and service worker termination for you, the Moderok SDK is 5.7 kB gzipped, needs only the storage permission, and keeps its storage footprint bounded by design.