Saving progress in web games: localStorage, quotas and portal saves
How localStorage behaves in portal iframes, which exceptions to catch, a tested versioned save helper, and the save APIs of CrazyGames, Poki, Y8 and YouTube.
A portal game’s save code has to cope with storage that is blocked, partitioned per embedding site, or deleted later, and several portals add their own cloud-save APIs with their own limits. This guide covers the browser rules, a small save helper, and each portal’s save API as documented on 2026-09-30.
What localStorage gives you
localStorage is a per-origin Storage object that survives browser restarts. Keys and values are UTF-16 strings, so objects go through JSON.stringify(). http: and https: get separate areas, private-window data is cleared when the last private tab closes, and file: behaviour is undefined, so test saves from a local web server.
Quotas and eviction
MDN lists 5 MiB of localStorage and 5 MiB of sessionStorage per origin; past that, writes throw QuotaExceededError. Browser data is best-effort by default. Under storage pressure, browsers evict the least recently used origins, and an origin’s data is deleted all at once, not key by key.
Safari adds two rules that matter for games:
- Seven-day cap. With tracking prevention on, WebKit deletes all script-writable storage, including localStorage and IndexedDB, after seven days of Safari use without user interaction on the site. Web apps added to the Home Screen keep their own day counter.
- Smaller quota in frames. WebKit’s storage policy for Safari 17 gives a cross-origin frame its own storage partition, with a quota of 10% of the main frame’s origin quota.
navigator.storage.persist() asks for persistent storage, but the browser may or may not honour it; WebKit decides with heuristics such as whether the site runs as a Home Screen web app. Treat a local save as something the player can lose.
The two exceptions to catch
The HTML Standard defines both (Web storage):
SecurityErroris thrown when you read thelocalStorageproperty, if the document’s origin is opaque or a policy decision blocks storage. MDN adds that blocking cookies will probably be treated the same way. Code likeconst ls = localStorage;outside atrycan stop your game at boot.QuotaExceededErroris thrown bysetItem()when the value cannot be stored: quota reached, or storage disabled by the user. MDN’s DOMException page lists it with legacy code 22 and notes it is now its own interface derived fromDOMException, so checkerr.nameand fall back toerr.code.
Poki makes this a hard requirement: incognito mode restricts localStorage, calls must be wrapped in try/catch, and the game must stay playable.
Portal embeds and storage partitioning
Portals load your build in an iframe on their own site. All three major engines now key that iframe’s storage by the top-level site as well as your origin:
- Chrome has partitioned third-party storage since Chrome 115: data written in an iframe is only readable by contexts with the same origin and the same top-level site.
- Firefox has double-keyed client-side state by origin and top-level site since Firefox 103.
- WebKit partitions third-party localStorage and IndexedDB per first-party site and also makes it ephemeral. The same page defines ephemeral storage as not persisted to disk: it goes away with the application, for instance when the user quits the browser or reboots the device.
So a save made on your own domain is invisible to the same build embedded on a portal, and a save on one portal is invisible on another. Do not promise progress across sites, and test the build inside an iframe on a page from a different site.
A versioned save helper
The helper below stores one versioned JSON document under a single key and upgrades old saves one step at a time. It accepts any object with getItem/setItem, which is the shape of the CrazyGames data module, and keeps the latest state in memory when storage is missing or full.
Its main job is to never destroy progress. load() reports a status as well as the state, and three statuses lock the store, so save() keeps the session in memory but writes nothing to storage:
unreadable: the stored text is not a valid save. The original stays untouched until the game callsrecover(), which first copies it to a backup key and stays locked if that backup fails.newer: a newer version of the game wrote the save. An old build must not overwrite it.read-error: reading storage threw (for example storage is blocked right now). That is not the same as “no save”, so nothing is written until a laterload()succeeds.
// file: examples/snippets/saving-progress-localstorage/save-store.js
// save-store.js: versioned JSON saves on localStorage (or any getItem/setItem backend), with an
// in-memory fallback. A save that cannot be read is never overwritten: saves stay in memory until
// the game calls recover(), which first backs up the unreadable original.
export const SAVE_VERSION = 3;
const DEFAULTS = {
version: SAVE_VERSION,
level: 1,
coins: 0,
settings: { muted: false, volume: 0.8 },
};
// One step per old version; each returns the next version's shape.
// Version 1 saves were written before the version field existed.
const MIGRATIONS = {
1: (s) => ({ version: 2, level: s.level ?? 1, coins: s.gold ?? 0, muted: !!s.muted }),
2: (s) => ({ version: 3, level: s.level, coins: s.coins, settings: { muted: s.muted, volume: 0.8 } }),
};
const clone = (o) => JSON.parse(JSON.stringify(o));
export function isQuotaError(err) {
return !!err && (err.name === 'QuotaExceededError' || err.code === 22);
}
// Returns localStorage if it can be read and written, otherwise null.
function openLocalStorage(probeKey) {
try {
const ls = globalThis.localStorage; // the getter itself can throw SecurityError
if (!ls) return null;
ls.setItem(probeKey, '1');
ls.removeItem(probeKey);
return ls;
} catch {
return null;
}
}
// load() status values:
// fresh no save yet saves persist
// loaded save read (and migrated) saves persist
// no-storage storage unavailable saves stay in memory
// unreadable stored text is not a valid save LOCKED until recover()
// newer written by a newer game version LOCKED (update the game)
// read-error reading storage threw LOCKED until a later load() succeeds
const LOCKED = new Set(['unreadable', 'newer', 'read-error']);
/**
* @param {{ key?: string, probeKey?: string,
* backend?: { getItem(k: string): string | null, setItem(k: string, v: string): void } | null }} [options]
* backend: any getItem/setItem object (default: localStorage if usable); null means "no storage".
*/
export function createSaveStore({ key = 'mygame.save', backend, probeKey = 'poki_ignore_probe' } = {}) {
const store = backend === undefined ? openLocalStorage(probeKey) : backend;
let status = store ? 'fresh' : 'no-storage';
let unreadableRaw = null; // the original text, kept so recover() can back it up
let session = null; // latest state of this session, always kept in memory
function load() {
if (!store) return { state: clone(DEFAULTS), status };
let raw;
try {
raw = store.getItem(key);
} catch {
status = 'read-error'; // not "no save": the save may exist but cannot be read right now
return { state: session ?? clone(DEFAULTS), status };
}
if (raw === null || raw === undefined) {
status = 'fresh';
return { state: clone(DEFAULTS), status };
}
let data;
try {
data = JSON.parse(raw);
} catch {
data = null;
}
if (typeof data !== 'object' || data === null) {
status = 'unreadable';
unreadableRaw = raw;
return { state: clone(DEFAULTS), status };
}
let v = Number.isInteger(data.version) ? data.version : 1;
while (v < SAVE_VERSION && MIGRATIONS[v]) {
data = MIGRATIONS[v](data);
v = data.version;
}
if (v !== SAVE_VERSION) {
status = 'newer'; // a newer build wrote it, or there is no migration path
return { state: clone(DEFAULTS), status };
}
status = 'loaded';
return { state: { ...clone(DEFAULTS), ...data }, status };
}
function save(state) {
session = clone({ ...state, version: SAVE_VERSION });
if (LOCKED.has(status)) return { ok: false, persisted: false, reason: status };
if (!store) return { ok: true, persisted: false, reason: 'no-storage' };
const raw = JSON.stringify(session);
try {
store.setItem(key, raw);
return { ok: true, persisted: true, bytes: new TextEncoder().encode(raw).length };
} catch (err) {
return { ok: true, persisted: false, reason: isQuotaError(err) ? 'quota' : 'error' };
}
}
// Call only after the player agreed to start over. Backs up the unreadable save first; if the
// backup fails, the store stays locked so the only copy is not lost.
function recover() {
if (status !== 'unreadable') return { ok: false, reason: `nothing to recover (${status})` };
const backupKey = `${key}.unreadable-${Date.now()}`;
try {
store.setItem(backupKey, unreadableRaw);
} catch {
return { ok: false, reason: 'backup-failed' };
}
status = 'fresh';
unreadableRaw = null;
return { ok: true, backupKey };
}
return {
load,
save,
recover,
get status() {
return status;
},
};
}
Use it at boot and at save points the player caused, such as finishing a level:
import { createSaveStore } from './save-store.js';
const saves = createSaveStore({ key: 'mygame.save' });
let { state, status } = saves.load();
if (status === 'unreadable' && (await askPlayer('Your save could not be read. Start over?'))) {
const r = saves.recover(); // backs up the unreadable save first
if (!r.ok) showNotice('Your old save is kept; progress will not be saved in this session.');
}
function onLevelComplete() {
state.level += 1;
const result = saves.save(state);
if (!result.persisted) showNotice('Progress is kept only until this tab closes.');
}
Notes on the design:
- Keep real save strings from each released version and run them through
load()in tests. bytesis the UTF-8 size of the JSON; compare it with the portal limits below.- The probe key starts with
poki_ignorebecause Poki’s cloud gamesaves skip keys with that prefix. Use it for caches and other local-only data too. askPlayerandshowNoticestand for your own UI.
The helper is in the site’s repository with unit tests (tests/examples/snippets-saving-progress.test.ts) that run it under Node.js against a mock Storage object, not in a browser. They cover a fresh start, migrations, corrupt JSON, a failed backup during recovery, a failed read, a newer-version save, QuotaExceededError and missing storage.
When to use IndexedDB instead
IndexedDB is an asynchronous, transactional store for larger structured data, including blobs, and it stores anything the structured clone algorithm supports. Choose it for level editors, replays or large binary data. It does not escape the rules above: eviction removes an origin’s IndexedDB data along with the rest, WebKit’s seven-day cap covers it, and it is partitioned in iframes. Engines that already save to IndexedDB (Unity’s PlayerPrefs on the web, per CrazyGames’ automatic progress save page) still need the same error handling.
Portal save APIs
| Portal | Mechanism | Published limit |
|---|---|---|
| CrazyGames | SDK data module: getItem, setItem, removeItem, clear |
1 MB (JSON string over 1,048,576 bytes throws dataLimitExcedeed) |
| Poki | Cloud gamesaves for logged-in users, syncing localStorage and IndexedDB automatically | 1 MB after gzip; above that, cloud saves stop for that player |
| Y8 | Cloud Storage: saveData, loadData, removeData; player must be authenticated |
30 KB per value |
| YouTube Playables | ytgame.game.saveData / loadData |
Under 3 MiB, should be under 500 KiB (stability) |
CrazyGames. Guest data goes to localStorage and syncs to the account at login. Select the Progress Save option at submission or the module is disabled; call it after await window.CrazyGames.SDK.init() and do not also rely on local saves. Writes are debounced by 1 second (up to 30). Progress save is a Full Implementation requirement wherever progress applies; the automatic alternative is not allowed with in-game purchases. The API matches Storage, so window.CrazyGames.SDK.data works as the helper’s backend.
Poki. Cloud gamesaves need no extra calls: the SDK loads the save before the game starts and syncs changes at a throttled interval.
Y8. saveData retries rate-limited saves up to five times, three seconds apart, then fails with Y8Sdk.saveRejected. loadData resolves null for a missing key. Y8’s best practices ask you to throttle auto-saves to at most once a minute and to tell the player when a save failed.
YouTube Playables. saveData must be the only mechanism for saving progress. A saveData call made before loadData completes is rejected. Saves from previous game versions must load without errors. The exit flush save is best effort and capped at 64 KiB.
// YouTube Playables: load first, then save through the SDK only.
let saveLoaded = false;
async function loadPlayablesSave() {
const raw = await ytgame.game.loadData(); // rejects with ytgame.SdkError
saveLoaded = true;
return raw ? JSON.parse(raw) : null; // then apply your migrations
}
async function savePlayables(state) {
if (!saveLoaded) return; // would be rejected anyway
await ytgame.game.saveData(JSON.stringify(state));
}
GameDistribution, GameMonetize and itch.io save facilities were not checked for this guide.
What to check before you submit
- Every
localStorageaccess, including the property read, is insidetry; the game plays with site data blocked and in a private window. - Saves from every released version load through the migrations.
- The game was tested inside a cross-site iframe.
- Save size is under the target portal’s limit.
- CrazyGames: Progress Save selected; SDK initialized before data calls.
- Poki: local-only keys prefixed
poki_ignore. - YouTube Playables:
loadDataawaited beforesaveData; no other save path. - Y8: auto-saves throttled; failures shown to the player.
- The Portal Readiness Checker run on the zip to list localStorage use.
Limits of this guide
Browser behaviour here comes from MDN, the HTML Standard, Google’s Privacy Sandbox docs and webkit.org, not from browser tests; the helper was checked only in Node with a mock. WebKit describes ephemeral third-party storage only in general terms (gone when the browser quits or the device reboots), so test what Safari keeps between sessions yourself. Portal limits change; recheck the linked pages, and the portal comparison, before release.
Questions
- Why does a save made on my own site not show up when the game runs on a portal?
- Portals embed games in an iframe on their own site. Chrome, Firefox and Safari key iframe storage by the top-level site as well as the origin, so the same build gets a separate storage area on every site that embeds it.
- Is a try/catch around setItem enough?
- No. Reading the localStorage property can itself throw a SecurityError, so the property access belongs inside the try block too, with an in-memory fallback so the game stays playable.
- How large can a save be?
- MDN gives 5 MiB of localStorage per origin. Portal cloud saves are smaller or different: 1 MB for the CrazyGames data module, 1 MB after gzip on Poki, 30 KB per value on Y8, and under 3 MiB (ideally under 500 KiB) on YouTube Playables.
Sources
- Window: localStorage property (MDN)
- Storage: setItem() method (MDN)
- Using the Web Storage API (MDN)
- Storage quotas and eviction criteria (MDN)
- DOMException (MDN)
- State Partitioning (MDN)
- IndexedDB API (MDN)
- StorageManager: persist() method (MDN)
- HTML Standard, section 12: Web storage (WHATWG)
- Storage Partitioning (Privacy Sandbox, Google)
- Updates to Storage Policy (WebKit blog)
- Full Third-Party Cookie Blocking and More (WebKit blog)
- Tracking Prevention in WebKit (WebKit)
- Data module (CrazyGames SDK docs)
- Account integration requirements (CrazyGames docs)
- Automatic progress save (CrazyGames docs)
- User Accounts and cloud gamesaves (Poki for Developers guide)
- Requirements and quality guidelines (Poki for Developers guide)
- Cloud Storage (Y8 SDK docs)
- Best practices (Y8 docs)
- YouTube Playables SDK reference (Google for Developers)
- YouTube Playables integration requirements (Google for Developers)
- YouTube Playables stability requirements (Google for Developers)