Save and load game state with IndexedDB
IndexedDB is the best option for persistent game data in the browser. It handles large amounts of data, works offline, and doesn't block the main thread.
1) Why IndexedDB over localStorage?
| Feature | localStorage | IndexedDB |
|---|---|---|
| Storage limit | ~5-10 MB | 50+ MB (often GB) |
| Data types | Strings only | Objects, blobs, arrays |
| Async | No (blocks) | Yes |
| Indexed queries | No | Yes |
For games, IndexedDB is almost always the right choice.
2) Opening a database
function openGameDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('MyGame', 1)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (event) => {
const db = event.target.result
// Create stores
if (!db.objectStoreNames.contains('saves')) {
db.createObjectStore('saves', { keyPath: 'slot' })
}
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings', { keyPath: 'key' })
}
}
})
}3) Saving game state
async function saveGame(slot, gameState) {
const db = await openGameDB()
return new Promise((resolve, reject) => {
const tx = db.transaction('saves', 'readwrite')
const store = tx.objectStore('saves')
const saveData = {
slot,
state: gameState,
timestamp: Date.now(),
}
const request = store.put(saveData)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}4) Loading game state
async function loadGame(slot) {
const db = await openGameDB()
return new Promise((resolve, reject) => {
const tx = db.transaction('saves', 'readonly')
const store = tx.objectStore('saves')
const request = store.get(slot)
request.onsuccess = () => resolve(request.result?.state || null)
request.onerror = () => reject(request.error)
})
}5) Listing all saves
async function listSaves() {
const db = await openGameDB()
return new Promise((resolve, reject) => {
const tx = db.transaction('saves', 'readonly')
const store = tx.objectStore('saves')
const request = store.getAll()
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}6) Deleting a save
async function deleteSave(slot) {
const db = await openGameDB()
return new Promise((resolve, reject) => {
const tx = db.transaction('saves', 'readwrite')
const store = tx.objectStore('saves')
const request = store.delete(slot)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}7) A complete GameStorage class
class GameStorage {
constructor(dbName = 'GameData', version = 1) {
this.dbName = dbName
this.version = version
this.db = null
}
async init() {
this.db = await this.openDB()
}
openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (e) => {
const db = e.target.result
if (!db.objectStoreNames.contains('saves')) {
db.createObjectStore('saves', { keyPath: 'slot' })
}
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings', { keyPath: 'key' })
}
if (!db.objectStoreNames.contains('assets')) {
db.createObjectStore('assets', { keyPath: 'url' })
}
}
})
}
async save(slot, data) {
const tx = this.db.transaction('saves', 'readwrite')
tx.objectStore('saves').put({ slot, data, timestamp: Date.now() })
return tx.complete
}
async load(slot) {
const tx = this.db.transaction('saves', 'readonly')
const result = await this.promisify(tx.objectStore('saves').get(slot))
return result?.data || null
}
async setSetting(key, value) {
const tx = this.db.transaction('settings', 'readwrite')
tx.objectStore('settings').put({ key, value })
}
async getSetting(key, defaultValue = null) {
const tx = this.db.transaction('settings', 'readonly')
const result = await this.promisify(tx.objectStore('settings').get(key))
return result?.value ?? defaultValue
}
promisify(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
}8) Storing binary data (textures, audio)
IndexedDB handles Blobs and ArrayBuffers:
async function cacheAsset(url, blob) {
const tx = db.transaction('assets', 'readwrite')
tx.objectStore('assets').put({ url, blob, cached: Date.now() })
}
async function getCachedAsset(url) {
const tx = db.transaction('assets', 'readonly')
const result = await promisify(tx.objectStore('assets').get(url))
return result?.blob || null
}9) Autosave pattern
class AutoSave {
constructor(storage, interval = 60000) {
this.storage = storage
this.interval = interval
this.timer = null
this.dirty = false
}
markDirty() {
this.dirty = true
}
start(getState) {
this.timer = setInterval(async () => {
if (this.dirty) {
await this.storage.save('autosave', getState())
this.dirty = false
console.log('Autosaved')
}
}, this.interval)
}
stop() {
clearInterval(this.timer)
}
}10) Error handling and fallbacks
async function safeLoad(slot, defaultState) {
try {
const saved = await loadGame(slot)
if (saved) {
// Validate/migrate old saves if needed
return migrateSave(saved)
}
} catch (err) {
console.warn('Failed to load save:', err)
}
return defaultState
}
function migrateSave(save) {
// Handle old save formats
if (!save.version) {
save.version = 1
save.settings = save.settings || {}
}
return save
}11) Make saves survive eviction
IndexedDB isn't guaranteed to stick around. By default an origin uses "best-effort" storage, and the browser can evict it when the disk fills up or, on Safari/WebKit, after a stretch of no user interaction with your site. For game saves that's exactly the data you don't want to lose.
Request persistent storage so the browser won't clear your data without an explicit user action:
async function makeStoragePersistent() {
if (navigator.storage && navigator.storage.persist) {
const persisted = await navigator.storage.persist()
console.log(persisted ? 'Saves are protected from eviction' : 'Saves may be evicted under storage pressure')
return persisted
}
return false
}Browsers decide whether to grant it based on engagement signals (how much the user interacts with your site, whether it's installed as a PWA), so don't assume it always succeeds. You can also check how much room you have before writing large saves or cached assets:
async function checkStorage() {
if (navigator.storage && navigator.storage.estimate) {
const { usage, quota } = await navigator.storage.estimate()
console.log(`Using ${usage} of ${quota} bytes`)
}
}Both APIs need a secure context (HTTPS or localhost).
Related
- PWA for offline games
- Service workers for game caching
- Ship a web game that loads fast
- Streaming asset loading — using IndexedDB as an asset cache
- Analytics for web games — tracking save/load patterns to understand player behavior
External Resources
- MDN: IndexedDB API — full API reference
- MDN: Using IndexedDB — step-by-step guide
- idb library — a tiny Promise-based IndexedDB wrapper