Quick answer: Use localStorage for small saves under 1 MB, IndexedDB for larger data, include a version number for migration, handle storage errors gracefully, and offer a file export option so players can back up their progress manually.
Web games face a unique challenge: you do not have direct access to the filesystem. All storage goes through browser APIs with size limits, eviction policies, and no guarantee of persistence. A solid web game save system needs to work within these constraints while giving players confidence that their progress is safe.
localStorage for Simple Saves
localStorage is the simplest storage API. It stores string key-value pairs synchronously and persists across browser sessions. It has a 5–10 MB limit per origin depending on the browser.
const SAVE_KEY = "game_save";
function saveGame(data) {
try {
const json = JSON.stringify(data);
localStorage.setItem(SAVE_KEY, json);
return true;
} catch (e) {
// QuotaExceededError if storage is full
console.error("Save failed:", e.message);
return false;
}
}
function loadGame() {
const json = localStorage.getItem(SAVE_KEY);
if (!json) return null;
try {
const data = JSON.parse(json);
return migrateSave(data);
} catch (e) {
console.error("Load failed:", e.message);
return null;
}
}
localStorage is synchronous, which means large writes can block the main thread and cause frame drops. If your save data is over a few hundred kilobytes, use IndexedDB instead.
IndexedDB for Large Saves
IndexedDB is an async, transactional database built into every modern browser. It supports much larger storage quotas than localStorage (typically 50 MB or more) and does not block the main thread during writes.
The raw IndexedDB API is notoriously verbose. Use a lightweight wrapper like idb-keyval or write a minimal one:
class SaveStore {
constructor(dbName = "game_saves", storeName = "saves") {
this.dbName = dbName;
this.storeName = storeName;
this.db = null;
}
async open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(this.storeName);
};
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onerror = () => reject(request.error);
});
}
async set(key, value) {
return new Promise((resolve, reject) => {
const tx = this.db.transaction(this.storeName, "readwrite");
tx.objectStore(this.storeName).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async get(key) {
return new Promise((resolve, reject) => {
const tx = this.db.transaction(this.storeName, "readonly");
const request = tx.objectStore(this.storeName).get(key);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async remove(key) {
return new Promise((resolve, reject) => {
const tx = this.db.transaction(this.storeName, "readwrite");
tx.objectStore(this.storeName).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
}
// Usage
const store = new SaveStore();
await store.open();
await store.set("slot_0", saveData);
const loaded = await store.get("slot_0");
IndexedDB stores structured data directly—you do not need to JSON.stringify before storing. It handles objects, arrays, and basic types natively.
Save Versioning
Include a version number in every save. When your save format changes, use the version to migrate old saves forward:
const CURRENT_VERSION = 2;
function migrateSave(data) {
let version = data.version || 0;
if (version < 1) {
// v0 -> v1: renamed "coins" to "gold"
if (data.coins !== undefined) {
data.gold = data.coins;
delete data.coins;
}
}
if (version < 2) {
// v1 -> v2: added achievements array
if (!data.achievements) {
data.achievements = [];
}
}
data.version = CURRENT_VERSION;
return data;
}
Storage Limits and Eviction
Browser storage is not permanent. Here is what can go wrong:
Quota limits: localStorage is capped at 5–10 MB. IndexedDB is larger but still has limits. Writes beyond the quota throw QuotaExceededError.
Eviction: Under storage pressure, browsers can evict data from origins that have not been visited recently. This is more aggressive on mobile browsers.
Private browsing: All storage is deleted when the private browsing window closes. Some browsers (older Safari) also limited storage to zero in private mode.
User action: Players can clear site data at any time through browser settings.
To check available storage and request persistence:
async function checkStorage() {
if (navigator.storage && navigator.storage.estimate) {
const estimate = await navigator.storage.estimate();
console.log("Used:", estimate.usage, "Quota:", estimate.quota);
}
// Request persistent storage (browser may prompt the user)
if (navigator.storage && navigator.storage.persist) {
const granted = await navigator.storage.persist();
console.log("Persistent storage:", granted);
}
}
Requesting persistent storage tells the browser not to evict your data under pressure. Not all browsers grant this automatically, but it significantly improves save reliability.
Multiple Save Slots
Use key naming conventions for slots in both localStorage and IndexedDB:
function slotKey(slot) {
return "save_slot_" + slot;
}
function getSlotInfo(slot) {
const json = localStorage.getItem(slotKey(slot));
if (!json) return { empty: true };
const data = JSON.parse(json);
return {
empty: false,
timestamp: data.timestamp,
level: data.currentLevel,
playtime: data.playtimeSeconds
};
}
function deleteSave(slot) {
localStorage.removeItem(slotKey(slot));
}
Export and Import Save Files
Since browser storage can be cleared, give players a way to export their save data as a downloadable file and import it back. This is essential for web games with significant playtime.
function exportSave(slot) {
const json = localStorage.getItem(slotKey(slot));
if (!json) return;
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "savegame_slot_" + slot + ".json";
a.click();
URL.revokeObjectURL(url);
}
function importSave(slot) {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const text = await file.text();
try {
const data = JSON.parse(text);
const migrated = migrateSave(data);
localStorage.setItem(slotKey(slot), JSON.stringify(migrated));
console.log("Import successful");
} catch (err) {
console.error("Invalid save file");
}
};
input.click();
}
Cloud Saves
For games where progress matters, sync save data to a server so players do not lose it when they clear their browser or switch devices:
async function cloudSave(data, token) {
try {
const response = await fetch("/api/saves", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token
},
body: JSON.stringify(data)
});
return response.ok;
} catch (e) {
console.warn("Cloud save failed, local save preserved");
return false;
}
}
async function cloudLoad(token) {
try {
const response = await fetch("/api/saves", {
headers: { "Authorization": "Bearer " + token }
});
if (!response.ok) return null;
return response.json();
} catch (e) {
return null;
}
}
Always save locally first, then sync to the cloud in the background. Network failures should never block the player from saving. When both local and cloud saves exist, compare timestamps and let the player choose which to load if they differ.
Autosave
Use setInterval for regular autosaves, and the visibilitychange event to save when the player switches tabs or closes the browser:
// Autosave every 5 minutes
setInterval(() => {
saveGame(gatherSaveData());
}, 5 * 60 * 1000);
// Save when the tab becomes hidden (player switching tabs or closing)
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
saveGame(gatherSaveData());
}
});
// Save before the page unloads
window.addEventListener("beforeunload", () => {
saveGame(gatherSaveData());
});
The visibilitychange event is more reliable than beforeunload on mobile browsers. Use both for maximum coverage. Note that beforeunload handlers must be synchronous, so use localStorage (not IndexedDB) for the emergency save on unload.
Related Issues
For debugging save corruption across all engines, see How to Debug Game Save Corruption Bugs. For general error logging best practices, read Best Practices for Game Error Logging.
Browser storage is borrowed, not owned. Always offer a file export option, request persistent storage, and save on visibilitychange. The players who care most about your game are the ones who will lose the most when their browser clears your data.