Saving player progress in a browser game
The question: "How do I save player progress in a web game so it survives across devices?"
The short version: localStorage alone will lose your players' progress, and on
a game portal it is often prohibited outright. What replaces it depends on where
the game is published, and the differences are large enough to design around
rather than paper over.
Why localStorage is not a save system
It works on your machine, which is what makes it so easy to ship and so disappointing later.
- It is scoped to one browser on one device. The same player opening your game in a different browser, or on their phone, sees a fresh save. Nothing travelled with them.
- Browsers evict it. Newer iOS versions clear site data aggressively; Yandex
documents this specifically as a reason to stop using
localStoragedirectly and provides a wrapper for it. Clearing cookies or browsing data takes the save with it. - Third-party context makes it worse. Your game usually runs in an iframe on the portal's domain. Storage partitioning and third-party storage restrictions apply, and they are tightening, not loosening.
- An ad blocker toggle can cost the save. On CrazyGames, turning an ad blocker off usually requires a page refresh, and their guidance is explicit: make sure progress is saved before that happens.
There is also a rule dimension. YouTube Playables prohibits any alternative progress-saving mechanism beyond its own cloud save. Yandex requires that a page refresh must not affect saved data, and requires server-side progress available to one user across devices for any game with in-app purchases.
What each portal gives you instead
The mechanisms differ more than the names suggest.
| Portal | Mechanism | Size limit | Notes |
|---|---|---|---|
| CrazyGames | Data module, a localStorage-shaped API | 1 MB per user | Guests get local storage, transparently migrated to the account on login. Writes are debounced about 1 second. |
| CrazyGames | Automatic Progress Save | not published | Zero code: backs up localStorage and IndexedDB. Not allowed for games with in-game purchases. |
| Poki | Automatic cloud sync while logged in | 1 MB after gzip | No SDK calls at all; it mirrors your local storage. Over the cap, cloud saves are silently disabled for that player. |
| Yandex | player.setData / getData | 200 KB per player | Rate limited to 100 requests per 5 minutes. Numeric stats are separate, at 10 KB. |
| YouTube Playables | saveData() / loadData() | 3 MB, should be under 500 KB | Exit flush is best effort only and capped at 64 KiB. |
Three consequences worth designing for up front:
Your save has a budget, and it is smaller than you think. 200 KB on the tightest platform, and 1 MB after gzip on another. A save that serialises the full world state per level will not fit. Store what you cannot recompute.
Guest players are the normal case, not an edge case. CrazyGames states guest-first as the main scenario and prohibits auto-triggering the login prompt. Poki returns null from its user lookup for a logged-out player and expects you to handle it without blocking the game. Design the save to work for a player who never signs in, and to migrate cleanly if they do.
Some portals sync automatically and some do not. Poki mirrors your storage with no API calls; YouTube Playables requires explicit calls and forbids any other mechanism. The same code cannot be correct on both without a layer in between.
The ordering rule that silently drops saves
This is the bug that is hardest to find, because nothing errors and the game keeps running.
Load must complete before the first save. YouTube Playables states it as a
requirement and enforces it: a saveData() call issued before loadData()
resolves is rejected. Not queued. Rejected.
The failure looks like this. The game boots, starts a load, and meanwhile the player finishes the tutorial, which triggers a save. The save is rejected. Then the load resolves with the old cloud data and overwrites what the player just did. The player replays the tutorial next session and you cannot reproduce it, because on your machine the load resolves before the player gets that far.
// Wrong: the save races the load, and on a slow connection it loses.
loadProgress().then(applyProgress);
startGame();
// Right: nothing can save until the load has resolved, one way or the other.
const progress = await loadProgress().catch(() => defaultProgress());
applyProgress(progress);
startGame(); // saves are only reachable from here
The general form of the rule, which holds on every portal even where it is not enforced: never write until you have read. CrazyGames gives the same advice for its Data module, to avoid overwriting existing progress.
Because only one portal actually rejects the out-of-order write, this is a bug you can ship everywhere else and only discover on the strictest platform. The Yes2SDK data module holds the ordering for you on every target rather than only where it is enforced, so the behaviour you test locally is the behaviour you get on the platform that checks.
When to save
- On material progress, not on a timer. Level change, milestone, purchase, a meaningful currency change. YouTube Playables requires a save after material progress, which implies autosave rather than a save button.
- On pause. YouTube Playables recommends saving when its pause callback fires, and that callback is also when the player may be about to leave.
- Not on every frame or every coin. Yandex rate-limits writes to 100 per 5 minutes, and CrazyGames debounces about a second. A save-per-pickup either gets throttled or wastes the budget.
- Do not rely on an exit flush. YouTube Playables describes it as best effort only, with a 64 KiB cap. A save that only exists at exit does not exist.
Two details that bite later
Old saves must keep loading. YouTube Playables requires handling cloud save data from previous game versions without errors or crashes. Version your save payload from the first release, even when there is only one version, because adding a version field to data already in the wild is much harder than reading one.
Not everything should sync. Poki lets you exclude data by prefixing the
localStorage key or IndexedDB store name with poki_ignore. Device-specific
settings, cached assets and anything sensitive belong there rather than in the
synced payload, which also keeps you under the gzip cap.
Doing it once
Every mechanism above is reasonable on its own. The cost is that they are five different shapes: two sync automatically and three do not, the caps differ by more than an order of magnitude, one enforces load-before-save and the others merely recommend it, and one is incompatible with your own in-game purchases.
That is what the Yes2SDK data module exists to flatten. One integration ships your HTML5 game to every supported web game platform. You write one save path with one read-before-write ordering, and the QA Inspector checks the result against each portal's real rules, so a save issued before the load resolved or a payload over the tightest cap shows up as a failed check rather than as lost player progress you hear about in a review.
The data module reference has the API, and each supported platform's requirement sheet carries its current caps. They are listed in the docs index, and the API overview carries the current support matrix.