Saving Game Data in a PWA

Updated June 2026
Game saves in a PWA live in the browser's storage, which means they survive page reloads, browser restarts, and device reboots, but they can be lost if the player clears browser data or if the browser evicts storage under pressure. Building a reliable save system requires choosing the right storage API, structuring data for versioning and migration, requesting persistent storage, and optionally syncing to a server for cross-device recovery.

Players care deeply about save data. Losing progress in a game creates frustration that no amount of good gameplay design can overcome. In native apps, save data lives in the app's sandbox directory and persists until the app is uninstalled. In PWAs, the browser controls the storage layer, which introduces both flexibility and risk. Understanding how browser storage works and using it correctly is one of the most important technical decisions in a PWA game.

Step 1: Choose the Right Storage API

The browser offers two primary storage mechanisms for game data, each suited to different complexity levels.

localStorage stores string key-value pairs synchronously. It has a limit of 5-10MB per origin (varies by browser), persists until explicitly cleared, and works with a simple getItem/setItem API. For games with straightforward save data, like the current level number, a high score, basic settings, and a few unlocked achievements, localStorage is perfectly adequate. The synchronous API blocks the main thread during reads and writes, but for data under 1MB this is imperceptible even on low-end mobile devices.

IndexedDB is an asynchronous, transactional database built into every modern browser. It stores structured data (objects, arrays, dates, binary blobs), supports indexed queries, and has much larger storage limits, typically 50% of available disk space on desktop browsers and a more conservative quota on mobile. Games with inventory systems, multiple save slots, crafting recipes, world state, replay data, or any complex data structure should use IndexedDB.

The IndexedDB API is verbose, but lightweight wrapper libraries simplify it dramatically. The idb library by Jake Archibald provides a Promise-based wrapper that reduces database operations to a few lines. You open a database, define object stores (similar to tables), and read/write data with simple async function calls. The library adds minimal overhead and is well-tested across browsers.

A common pattern is to use both: localStorage for lightweight session data (settings, UI preferences, "last played" timestamps) and IndexedDB for the heavy game state (save files, inventory, world data). This keeps the critical-path data access fast while still supporting complex state.

Step 2: Structure Your Save Data

Design your save format with versioning from the start. Include a version field in every save object so you can migrate old saves when you update the game. A save from version 1 might not have fields that version 3 expects, and the migration code needs to know which version it is working with.

Separate your save data into logical sections. A clean structure might include: a player section with character stats, health, and position; an inventory section with items and quantities; a progression section with completed levels, unlocked areas, and achievements; a settings section with volume, difficulty, and control preferences; and a meta section with save timestamp, play time, and game version. This separation makes it easy to load only the data you need (settings load on startup, full save loads on "Continue Game") and simplifies migration code.

Use plain JavaScript objects as your save format rather than custom binary formats. JSON serialization is fast, human-readable for debugging, and compatible with both localStorage (via JSON.stringify) and IndexedDB (which stores objects natively). Binary formats add complexity without meaningful performance benefit for typical save data sizes.

Keep timestamps as Unix epoch numbers rather than Date objects or formatted strings. Unix timestamps are compact, sortable, and work reliably across serialization boundaries. Use them for save creation time, last-modified time, and play session duration tracking.

Step 3: Implement Auto-Save and Manual Save

Auto-save at predictable moments: level completion, checkpoint reached, significant inventory change, entering a new area, and at regular intervals (every 2-5 minutes of active play). Auto-saves should be invisible to the player except for a brief save indicator (a small icon or text flash) confirming the action. Never interrupt gameplay with a save dialog or loading screen for auto-saves.

Provide manual save for player control. A "Save Game" option in the pause menu lets players save at any moment they choose. Manual saves should go into named or numbered slots, not overwrite the auto-save. This gives players the safety net of auto-save plus the control of manual saves. Display the timestamp, play time, and a brief location or progress description for each save slot so players can identify their saves at a glance.

Implement save debouncing. If the game triggers multiple save events in rapid succession (player picks up three items quickly), batch them into a single write. Use a debounce timer that waits 500 milliseconds after the last save trigger before actually writing to storage. This reduces storage write frequency without losing data, since the most recent state is what matters.

Handle write errors gracefully. IndexedDB operations can fail if the storage quota is exceeded or if the database is in an unexpected state. Wrap all write operations in try-catch blocks. If a save fails, show a clear message to the player ("Could not save, storage may be full") and suggest freeing space or exporting existing saves. Never silently discard save data.

Step 4: Request Persistent Storage

By default, browser storage is "best-effort," meaning the browser can evict it when the device runs low on storage space. For a game where save data represents hours of player investment, this eviction risk is unacceptable. The Storage API provides a mechanism to request persistent storage that the browser will not automatically evict.

Call navigator.storage.persist() to request persistence. On Chrome, this is automatically granted for installed PWAs, frequently visited sites, or sites with notification permissions. On Firefox, the browser prompts the user. On Safari, the behavior is inconsistent, and full persistence is not guaranteed. Always request persistence but do not depend on it being granted.

Use navigator.storage.estimate() to check how much storage your game is using and how much quota remains. Display this information in a settings or debug menu so players can see their storage usage. If usage approaches the quota, warn the player and suggest exporting saves or clearing old data.

Even with persistent storage, educate players that browser data clearing, device factory resets, or uninstalling the PWA will delete local save data. This is why cloud sync and file export are important complementary features rather than optional nice-to-haves.

Step 5: Add File Export and Import

File export gives players a tangible backup of their progress. Read the save data from IndexedDB, serialize it to JSON, create a Blob from the JSON string, generate a download URL with URL.createObjectURL(), and trigger a download by creating and clicking a temporary anchor element. Name the file descriptively, like mygame-save-2026-06-13.json, so the player can identify it later.

File import works through an <input type="file"> element. When the player selects a save file, read it with FileReader, parse the JSON, validate the structure and version number, and write the data into IndexedDB. Show a confirmation dialog before overwriting existing save data, and create a backup of the current state before importing.

Validate imported data thoroughly. Check that the version field exists, that required sections are present, and that data types match expectations. Malformed files (from corruption, manual editing, or a different game) should produce a clear error message rather than crashing the game or corrupting the database.

Consider adding save file compression for games with large state. The CompressionStream API (supported in Chrome and Safari) can gzip the JSON before creating the Blob, significantly reducing file size for saves with large inventory or world data. Decompress on import with DecompressionStream.

Step 6: Implement Cloud Sync

Cloud sync protects against data loss and enables cross-device play. The pattern is straightforward: save locally to IndexedDB on every state change (this is the primary save), and periodically sync the local state to a server when the network is available.

Design the sync protocol with offline-first in mind. The local save is always the source of truth during gameplay. When the game initializes with a network connection, it checks the server for a more recent save. If the server save is newer (the player played on another device), offer to load the cloud save or keep the local one. If the local save is newer, push it to the server in the background.

Conflict resolution can be simple for most games. A "last write wins" strategy works when players use one device at a time: compare timestamps and keep the most recent save. For games where players might legitimately play on two devices simultaneously (rare but possible), implement per-field merging that takes the maximum value for numeric fields (highest score, most resources) and the most recent value for state fields (current level, equipped items).

Use the service worker's background sync or periodic sync capability to handle the network communication. Register a sync event when a new save is written, and the browser will fire the sync handler when connectivity is available, even if the game tab is closed. On iOS, where Background Sync is not supported, trigger the sync manually when the game resumes or when the network status changes to online.

Keep the server-side simple. A single API endpoint that accepts and returns JSON save objects, keyed by a user identifier and a game identifier, is sufficient for most games. Store the saves in a database or object storage. Authentication can be as simple as a device-generated UUID for anonymous saves, or integrate with a proper auth system for account-based saves.

Key Takeaway

Use IndexedDB for complex game saves and localStorage for simple data. Version your save format, request persistent storage, provide file export for player-controlled backups, and implement cloud sync for cross-device protection. Always handle storage errors gracefully and never silently lose player progress.