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.

Published
Sources checked

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:

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):

  • SecurityError is thrown when you read the localStorage property, 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 like const ls = localStorage; outside a try can stop your game at boot.
  • QuotaExceededError is thrown by setItem() 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 from DOMException, so check err.name and fall back to err.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 calls recover(), 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 later load() 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.
  • bytes is the UTF-8 size of the JSON; compare it with the portal limits below.
  • The probe key starts with poki_ignore because Poki’s cloud gamesaves skip keys with that prefix. Use it for caches and other local-only data too.
  • askPlayer and showNotice stand 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 localStorage access, including the property read, is inside try; 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: loadData awaited before saveData; 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

  1. Window: localStorage property (MDN)developer.mozilla.org, accessed
  2. Storage: setItem() method (MDN)developer.mozilla.org, accessed
  3. Using the Web Storage API (MDN)developer.mozilla.org, accessed
  4. Storage quotas and eviction criteria (MDN)developer.mozilla.org, accessed
  5. DOMException (MDN)developer.mozilla.org, accessed
  6. State Partitioning (MDN)developer.mozilla.org, accessed
  7. IndexedDB API (MDN)developer.mozilla.org, accessed
  8. StorageManager: persist() method (MDN)developer.mozilla.org, accessed
  9. HTML Standard, section 12: Web storage (WHATWG)html.spec.whatwg.org, accessed
  10. Storage Partitioning (Privacy Sandbox, Google)privacysandbox.google.com, accessed
  11. Updates to Storage Policy (WebKit blog)webkit.org, accessed
  12. Full Third-Party Cookie Blocking and More (WebKit blog)webkit.org, accessed
  13. Tracking Prevention in WebKit (WebKit)webkit.org, accessed
  14. Data module (CrazyGames SDK docs)docs.crazygames.com, accessed
  15. Account integration requirements (CrazyGames docs)docs.crazygames.com, accessed
  16. Automatic progress save (CrazyGames docs)docs.crazygames.com, accessed
  17. User Accounts and cloud gamesaves (Poki for Developers guide)developers.poki.com, accessed
  18. Requirements and quality guidelines (Poki for Developers guide)developers.poki.com, accessed
  19. Cloud Storage (Y8 SDK docs)docs.y8.com, accessed
  20. Best practices (Y8 docs)docs.y8.com, accessed
  21. YouTube Playables SDK reference (Google for Developers)developers.google.com, accessed
  22. YouTube Playables integration requirements (Google for Developers)developers.google.com, accessed
  23. YouTube Playables stability requirements (Google for Developers)developers.google.com, accessed