Browser games store progress in localStorage by default. It works well enough on a single device, but it has real limits that bite you the moment players care about their progress. localStorage is device-bound — a player who builds a 200-hour farm on their laptop has zero access to it on their phone. It's volatile — clearing browser data wipes everything. And it's small — most browsers cap it at 5-10MB per origin.
If your game has any retention at all, players will eventually ask: "Can I play on my other device?" or worse, they'll lose their save and never come back. Cloud saves solve both problems, but building a sync system for a browser game introduces challenges you won't find in native game development.
Before you build cloud sync, understand exactly how localStorage fails. These aren't edge cases — they're common enough that your players will hit them.
setItem() calls. Modern Safari allows writes but wipes them when the tab closes. Firefox and Chrome private mode behave similarly. If your game relies on localStorage as the only save mechanism, private-browsing players lose everything every session.getItem() and setItem() are synchronous. On a large save (500KB+), the stringify-and-write operation can cause a visible frame stutter. This gets worse on low-end phones.IndexedDB solves most of localStorage's pain points. It's asynchronous, so reads and writes don't block the game loop. It supports structured data natively — you can store objects, arrays, and binary blobs without serializing to strings. And the storage limits are dramatically higher: most browsers allow hundreds of megabytes, sometimes gigabytes, per origin.
The trade-off is complexity. IndexedDB has a callback-heavy API that feels archaic compared to modern JavaScript. Wrap it in a Promise-based helper or use a library like idb (by Jake Archibald, under 2KB gzipped) that makes it feel like a normal async key-value store.
IndexedDB also survives Safari's 7-day purge if you request persistent storage via navigator.storage.persist(). Browser support is excellent — every modern browser has had IndexedDB since 2015. Use it as your primary local store, with localStorage as a fallback for settings and small metadata.
| Feature | localStorage | IndexedDB |
|---|---|---|
| API | Synchronous | Asynchronous |
| Capacity | 5-10 MB | Hundreds of MB+ |
| Data types | Strings only | Objects, blobs, arrays |
| Safari purge risk | High (7 days) | Low (with persist()) |
| Performance impact | Blocks main thread | Non-blocking |
Not every browser game needs a backend. If your game is a quick 10-minute puzzle, localStorage is fine. But you need cloud sync when:
A cloud save backend can be surprisingly simple. At its core, you need three things: an identity, a storage endpoint, and versioned save slots.
Identity. For casual browser games, anonymous device IDs work well. Generate a UUID on first launch, store it in both localStorage and IndexedDB, and use it as the player's identity. If you want account linking later, let players attach an email to their anonymous ID. Don't force signup to save — that kills retention.
REST endpoint. Two routes cover the basics: PUT /saves/:slotId to upload a save and GET /saves/:slotId to download one. Authenticate requests with the device ID token in a header. Add a GET /saves list endpoint so the game can show available save slots on the load screen.
Versioned slots. Store each save with a monotonically increasing version number. When the client saves, it sends its current version number. The server increments the version on write and returns the new version. This lets you detect conflicts: if the client sends version 5 but the server is on version 6, someone else (or another device) wrote in between.
Conflicts happen when a player plays offline on two devices and then both come online. You have three options:
For most indie browser games, player choice with a last-write-wins fallback (if the player dismisses the dialog) is the best balance of simplicity and correctness.
Your cloud save format should be a superset of your local save format with additional metadata for sync. Include a schema version, a sync version (server-assigned), a client ID, and a checksum.
When you add new game features, bump the schema version and write a migration. The server should store saves as opaque blobs — it doesn't need to understand the save format. The client handles all migration logic on load.
Design your schema for backward compatibility from day one. New fields should have defaults so that an old save loaded without migration still produces a playable state. Never rename or remove fields in the same version — add a new field and deprecate the old one.
Save data grows as players progress. A farming game save might start at 2KB and grow to 200KB after 100 hours. Uploading 200KB every 30 seconds burns mobile data and hits rate limits.
CompressionStream (native browser API, no library needed) or pako for broader support. Send the compressed blob with Content-Encoding: gzip.Never trust client data. A player can open DevTools and send any JSON to your save endpoint. Server-side validation is essential:
Cloud saves should enhance local saves, not replace them. Design your system offline-first:
navigator.onLine before attempting a sync. Listen for the online event to trigger a sync when connectivity returns. Don't block gameplay on sync — it happens in the background.The golden rule: if the internet goes down mid-session, the player should notice nothing. They keep playing, saves keep writing locally, and everything syncs when the connection comes back. Your game should never show a "connection required" error for save functionality.
A cozy 3D farming game that runs right in your browser. No download, no signup.
Play Now