Save Systems for Web Games
From localStorage one-liners to IndexedDB migrations — keeping progress safe in browsers.
Saving player progress is deceptively hard. You need: durability (no wipes on refresh), versioning (so updates don’t brick old saves), and sync safety (players can close the tab mid-save). Here’s how to design a robust browser save layer.
1. Storage Options
| API | Pros | Cons | When to Use |
|---|---|---|---|
localStorage |
1-liner, ~5MB, sync with page reload | Blocking API, limited size, no binary | Simple idle/clickers, key-value saves |
IndexedDB |
Async, 100MB+, structured, transactions | Verbosity, browser quirks, needs wrapper | Complex games, asset caching, migrations |
File System Access API |
Player chooses file, true durability | Chrome-only, UX friction | Mod-friendly games, export/import saves |
2. Quick Experiment — localStorage
// save.js
const KEY = "neon-save-v1";
export function save(state){
try {
localStorage.setItem(KEY, JSON.stringify(state));
} catch(e){ console.error("Save failed", e); }
}
export function load(){
try {
return JSON.parse(localStorage.getItem(KEY)) || {};
} catch { return {}; }
}
// example
save({ coins: 100, level: 2 });
console.log(load()); // { coins: 100, level: 2 }
**Pitfall:** It’s synchronous. Large saves can stutter the game loop. Best for ≤ 50KB JSON.
3. Experiment — IndexedDB with Wrapper
// idb.js — tiny promise wrapper
export function openDB(name="neon-db", store="saves"){
return new Promise((resolve, reject) => {
const req = indexedDB.open(name, 1);
req.onupgradeneeded = () => req.result.createObjectStore(store);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export async function put(db, store, key, value){
return new Promise((res,rej) => {
const tx = db.transaction(store,"readwrite");
tx.objectStore(store).put(value,key);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
});
}
export async function get(db, store, key){
return new Promise((res,rej) => {
const tx = db.transaction(store,"readonly");
const req = tx.objectStore(store).get(key);
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
});
}
// usage
const db = await openDB();
await put(db,"saves","slot1",{coins:250,level:3});
console.log(await get(db,"saves","slot1"));
Now you can store megabytes safely, async, without blocking your frame budget.
4. Migration Example
// migrations.js
export function migrate(state){
if(!state.schema){ state.schema = 1; }
if(state.schema === 1){
// v1 → v2: add gems currency
state.gems = 0;
state.schema = 2;
}
return state;
}
5. Offline Safety
- Save often: every 30s or on critical events.
- Double-buffer: write to
save.tmp, then promote tosavewhen complete. - Beforeunload: add a final quick save — but don’t rely on it.
6. Bonus — Export & Import
// export
const blob = new Blob([JSON.stringify(saveState)], {type:"application/json"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "neon-save.json"; a.click();
// import
input.onchange = async e => {
const text = await e.target.files[0].text();
const data = JSON.parse(text);
loadState(data);
};
7. Checklist
- [ ] localStorage for quick JSON saves (<50KB)
- [ ] IndexedDB wrapper for scalable async saves
- [ ] Schema versioning + migrate()
- [ ] Autosave + double-buffering
- [ ] Optional: export/import for player backups