Game Saves and Player Data: Persistence for Web Games
In This Guide
- Why Data Persistence Matters for Web Games
- Browser Storage APIs for Games
- Save System Architecture
- Serialization and Save Formats
- Cloud Saves and Server-Side Storage
- Leaderboards and Competitive Data
- Player Accounts and Authentication
- Auto-Save Design Patterns
- Cross-Device Sync
- Player Data Privacy and Compliance
- Explore Game Save Topics
Why Data Persistence Matters for Web Games
A game without saves is a game without investment. Players who lose their progress when they close a browser tab rarely come back. The casual session nature of web games makes this problem worse, not better: players expect to open a tab, play for five minutes, close it, and resume later exactly where they left off. Mobile browsers are especially aggressive about killing background tabs to reclaim memory, which means a player who switches to check a text message might lose an hour of progress if the game relies solely on in-memory state.
Persistence enables the game design patterns that drive long-term engagement. Progression systems (leveling up, unlocking abilities, completing campaigns) require saving what the player has achieved. Economy systems (currencies, inventory, crafting resources) require saving what the player owns. Customization (character appearance, settings, keybinds) requires saving what the player prefers. High score tables require saving performance history. Without persistence, none of these systems can exist in a web game.
The web platform provides several built-in storage mechanisms that native games do not have access to. LocalStorage offers simple key-value storage with up to 5-10 MB per origin. IndexedDB provides a full object store with structured queries, transactions, and storage limits measured in hundreds of megabytes or more. The Cache API stores HTTP responses for offline access. Cookies, while technically available, are too small and too slow for game save data. Each storage API has different performance characteristics, capacity limits, and persistence guarantees, and choosing the wrong one creates problems that are difficult to fix after launch.
Cloud saves add a layer beyond the browser. When a player logs in from a different device, cloud saves let them pick up where they left off. Cloud saves also protect against data loss from cleared browser storage, reinstalled operating systems, and broken devices. The tradeoff is complexity: cloud saves require a server, authentication, conflict resolution when the same account plays on two devices simultaneously, and ongoing hosting costs. Most successful web games use a hybrid approach, saving locally for speed and syncing to the cloud for durability.
Browser Storage APIs for Games
LocalStorage is the simplest browser storage API and the most commonly used for small web games. It stores string key-value pairs that persist across browser sessions. The API surface is tiny: setItem(key, value), getItem(key), removeItem(key), and clear(). Because it only stores strings, game state objects must be serialized to JSON with JSON.stringify() before writing and parsed back with JSON.parse() when reading. LocalStorage is synchronous, meaning reads and writes block the main thread. For small save files (under 100 KB), this is imperceptible. For larger data, the blocking behavior can cause noticeable frame drops during save operations.
The storage limit for LocalStorage varies by browser but is typically 5 MB per origin (protocol + domain + port). This is enough for most 2D games, casual games, and games with compact state representations. It is not enough for games with large procedurally generated worlds, extensive replay data, or binary asset caching. LocalStorage is also shared across all pages on the same origin, so a game at example.com/game1 and another at example.com/game2 share the same 5 MB pool unless they use different keys carefully.
IndexedDB is the heavy-duty browser storage API. It stores structured data including objects, arrays, blobs, and binary data. It supports indexes for fast lookups, transactions for data integrity, and storage limits that browsers typically set at 50% of available disk space (often hundreds of megabytes or more). Unlike LocalStorage, IndexedDB is asynchronous, meaning save operations do not block the game loop. The tradeoff is a more complex API that requires opening databases, creating object stores, managing transactions, and handling version upgrades through event callbacks.
For games that store large amounts of data, IndexedDB is the correct choice. Open-world games that save per-chunk world modifications, games with user-generated content, games that cache downloaded assets for offline play, and games that store replay data all benefit from IndexedDB's capacity and performance. The asynchronous nature also means that auto-save operations can run in the background without causing frame rate hitches, which is critical for games that save frequently.
The Cache API is designed for caching HTTP responses and is primarily used by Service Workers for offline functionality. For games, the Cache API is useful for storing downloaded game assets (spritesheets, audio files, 3D models) so they load instantly on return visits. It is not well-suited for game state data because it operates on request/response pairs rather than arbitrary data structures. PWA games that need offline support typically use the Cache API for assets and IndexedDB for game state.
Cookies should not be used for game saves. They are limited to 4 KB, they are sent with every HTTP request (wasting bandwidth), and they have complex domain and path scoping rules. The only legitimate game-related use for cookies is session identifiers for authenticated cloud save systems.
Save System Architecture
A well-designed save system separates the game state model from the persistence layer. The game state model is a plain data structure (objects and arrays) that captures everything needed to reconstruct the game at a specific point. The persistence layer is the code that writes that data structure to storage and reads it back. This separation means you can change the storage backend (from LocalStorage to IndexedDB, or from local to cloud) without modifying any game logic, and you can modify the game state structure without rewriting the persistence code.
The game state model should contain only serializable data: numbers, strings, booleans, arrays, and plain objects. It should not contain references to DOM elements, canvas contexts, runtime instances, event listeners, or framework-specific objects. A save file should be a snapshot of the game's logical state, not its rendering state. For example, a platformer save includes the player's position (x, y), health, inventory array, current level number, and unlocked abilities. It does not include the player's sprite object, the tilemap renderer, or the audio context. When the save is loaded, the game creates fresh runtime objects and initializes them with the saved values.
Save slots are a common pattern that gives players control over their persistence. A three-slot system stores each save as a separate key in LocalStorage (such as save_slot_1, save_slot_2, save_slot_3) or as separate records in an IndexedDB object store. Each slot contains the full game state plus metadata: the slot name, a timestamp of when it was last saved, the player's current level or location for display purposes, and optionally a small screenshot or icon. The save/load screen reads the metadata for all slots to show the player what each contains without loading the full game state.
Versioning is essential because games change over time. When you add a new feature, fix a balance issue, or restructure game data, existing save files created before the change must still load correctly. The standard approach is to store a version number in every save file and write migration functions that upgrade saves from one version to the next. If the current game version is 5 and a save file is version 3, the loader runs the version-3-to-4 migration, then the version-4-to-5 migration, producing a valid version-5 save. Without versioning, any game update risks corrupting every player's saved progress.
Error handling in save systems must be defensive. Storage can fail for several reasons: the browser's storage quota is full, the user is in private browsing mode (which limits or disables persistence in some browsers), the save data is corrupted, or the browser throws a security exception. A robust save system catches errors at every write operation, notifies the player clearly when a save fails, and never silently drops data. Loading should validate the structure of saved data before applying it, falling back to default values for any missing or invalid fields rather than crashing.
Serialization and Save Formats
Serialization converts a game state object into a format that can be stored and later reconstructed. For web games, JSON.stringify() is the default serialization method. It handles numbers, strings, booleans, arrays, and plain objects. It does not handle Map, Set, Date, typed arrays, circular references, or any custom class instances. If your game state uses these types, you need custom serialization logic that converts them to JSON-compatible representations before stringifying and converts them back when parsing.
Save file size matters because it affects write speed, storage quota consumption, and cloud sync bandwidth. JSON is verbose by nature, with keys repeated for every object in an array and quotes around every string key. For games with large state (thousands of entities, detailed world maps), several strategies reduce save file size. Abbreviating keys (x instead of positionX) can cut file size by 30-50%. Using arrays instead of objects for homogeneous data (storing entity data as [x, y, hp, type] instead of {x: 1, y: 2, hp: 100, type: 3}) reduces overhead further. Applying compression (using the Compression Streams API to gzip the JSON string before storing) can achieve 60-80% size reduction for typical game data.
Binary formats offer maximum efficiency for games with very large state. Storing data as ArrayBuffers with a custom binary layout eliminates all the overhead of JSON keys and quotes. A 2D tile map stored as a Uint8Array of tile IDs is one byte per tile, whereas the same data as a JSON array of numbers averages 3-4 bytes per tile. IndexedDB can store ArrayBuffers directly, making binary saves a good fit for games that use IndexedDB as their persistence layer. The tradeoff is development complexity: you must manually define the byte layout, handle endianness, and build custom read/write functions instead of relying on JSON.stringify/parse.
Cloud Saves and Server-Side Storage
Cloud saves store game progress on a remote server, protecting it from browser cache clears, device changes, and data corruption. The basic architecture is straightforward: the game serializes its state, sends it to a server endpoint via an HTTP POST request, and the server stores it in a database keyed to the player's account. When the player loads the game, a GET request retrieves the latest save from the server, and the game deserializes it.
The choice of backend depends on scale. For indie web games with a few hundred players, a simple Node.js server with a SQLite or PostgreSQL database is sufficient. For games expecting thousands of concurrent players, managed services like AWS DynamoDB, Firebase Realtime Database, or Supabase provide automatic scaling, built-in authentication, and client SDKs that simplify integration. Firebase in particular is popular for web games because its JavaScript SDK handles authentication, real-time sync, and offline caching with minimal code.
Conflict resolution is the hardest problem in cloud save systems. When a player plays offline on their phone, makes progress, then plays on their laptop before the phone syncs, two different save files exist for the same account. The system must decide which one to keep. Common strategies include last-write-wins (the most recent timestamp overwrites), player-choice (showing both saves and letting the player pick), and merge-based (combining non-conflicting changes from both saves). Last-write-wins is simplest but can cause data loss. Player-choice is safest but interrupts gameplay. Merge-based is ideal but requires game-specific logic to define how different data fields combine.
Latency management is critical for cloud saves. A round trip to a server typically takes 50-300 milliseconds, which is acceptable for explicit save points but problematic for frequent auto-saves. The standard pattern is to save locally first (instantly) and sync to the cloud asynchronously in the background. The local save is the source of truth during gameplay, and the cloud save is a backup that catches up within seconds. This approach gives players instant save feedback while maintaining cloud durability.
Leaderboards and Competitive Data
Leaderboards transform solitary play into a competitive social experience. A high score board visible to all players motivates replaying levels, optimizing strategies, and competing with friends. Web games have a natural advantage for leaderboards because the game is already running in a browser with network access, making it trivial to submit and retrieve scores from a server.
The simplest leaderboard is a server endpoint that accepts a player name and score, stores it in a database, and returns the top N entries. PostgreSQL handles this well: a table with columns for player name, score, level, and timestamp, with an index on score descending. The query SELECT * FROM scores ORDER BY score DESC LIMIT 100 returns the global top 100 instantly. For large-scale leaderboards with millions of entries, Redis sorted sets provide O(log N) insertions and O(log N + M) range queries, making them the standard choice for real-time competitive games.
Anti-cheat is the unavoidable challenge with client-side web games. Because JavaScript runs in the browser where the player has full access to modify code and network requests, any score submitted by the client can be fabricated. No client-side solution can fully prevent cheating. Server-authoritative validation is the only reliable defense: the server replays or validates the game session to confirm the submitted score is achievable. For simpler games, heuristic checks (rejecting impossibly high scores, requiring a minimum play time, checking score progression patterns) catch most casual cheaters even if they cannot stop determined ones.
Player Accounts and Authentication
Player accounts connect save data to an identity rather than a browser. Without accounts, saves are tied to a specific browser on a specific device and vanish if the user clears their data. With accounts, saves persist in the cloud and follow the player across devices. Authentication also enables social features like leaderboards with real names, friend lists, and multiplayer matchmaking.
OAuth providers (Google, Apple, Discord, GitHub) are the standard authentication method for web games. Players sign in with an existing account instead of creating a new username and password, which reduces friction dramatically. Firebase Authentication, Auth0, and Supabase Auth provide pre-built OAuth flows with JavaScript SDKs that handle the entire sign-in process in a few lines of code. The game receives a unique user ID and an access token, which it sends with cloud save requests to prove identity.
Guest accounts with upgrade paths offer the lowest possible friction. The game generates a random anonymous ID on first visit, stores it locally, and uses it for cloud saves immediately without requiring any sign-in. If the player later wants to secure their account (to sync across devices or prevent data loss), they link an OAuth provider to the anonymous ID. This pattern keeps the initial experience friction-free while giving players the option to protect their progress when they choose to.
Auto-Save Design Patterns
Auto-save removes the burden of manual saving from the player. Modern players expect their progress to be preserved automatically, and forgetting to save manually feels like a design failure in 2026. The challenge is choosing when and how often to save without causing performance problems or saving the game in a bad state.
Event-driven auto-save triggers a save when something meaningful happens: completing a level, defeating a boss, picking up a key item, entering a new area, or closing the shop screen. This approach saves at natural checkpoints where the game state is clean and complete. It avoids saving mid-action (during a jump, mid-combat, or while a cutscene is playing), which can create confusing or broken states when loaded. Event-driven saves also minimize the number of write operations, which is important for LocalStorage where each write blocks the main thread.
Time-interval auto-save triggers a save every N seconds (typically 30-120 seconds), regardless of what the player is doing. This ensures that no more than N seconds of progress is ever lost, which is important for open-ended games without natural checkpoints (sandbox games, simulations, idle games). Interval saves should be asynchronous (using IndexedDB) to avoid frame rate drops, and they should include a dirty flag that skips the save if nothing has changed since the last save.
The beforeunload event provides a last-chance save opportunity when the player closes the tab or navigates away. This event is unreliable on mobile browsers (which kill tabs without firing events) and has a very short execution window, so the save must be fast. LocalStorage's synchronous nature actually helps here: a quick localStorage.setItem() call completes before the page unloads, whereas an asynchronous IndexedDB write might be interrupted. Many games use IndexedDB for regular saves and LocalStorage as a fallback for emergency saves on tab close.
Cross-Device Sync
Cross-device sync lets a player start a game on their desktop, continue on their phone during a commute, and pick it up on a tablet at home. For web games, this requires cloud saves tied to player accounts, plus a sync protocol that handles offline changes, version conflicts, and latency gracefully.
The basic sync flow is: on game start, load the local save and fetch the cloud save, compare timestamps, and use the newer one (or prompt the player if they differ significantly). During gameplay, save locally after every state change and sync to the cloud periodically (every 1-5 minutes) or on specific events. On game close, push a final sync to the cloud. Firebase Realtime Database and Supabase Realtime provide built-in sync protocols that handle most of this automatically, reducing the developer's work to defining the data schema and conflict resolution policy.
Player Data Privacy and Compliance
Web games that collect or store player data must comply with privacy regulations including GDPR (European Union), CCPA (California), and similar laws in other jurisdictions. If your game stores any data tied to an identifiable person, including email addresses, usernames, IP addresses, device fingerprints, or analytics events, you need a privacy policy, data processing disclosures, and in many cases explicit user consent before collecting data.
LocalStorage and IndexedDB data stored in the player's browser is generally not subject to the same regulations as server-side data, because it stays on the user's device and is controlled by the user. Cloud saves, leaderboard entries, analytics data, and authentication records stored on your servers are subject to privacy regulations. If your game has cloud saves with user accounts, you need to provide a way for players to view their data, request deletion, and export their data. These requirements apply regardless of whether you monetize the data.
The practical approach for indie web game developers is: use a privacy-respecting analytics tool or no analytics at all, implement a data deletion API endpoint that removes all server-side data for a given account, include a simple privacy policy that describes what you collect and why, and get consent before setting any tracking cookies. For games that store only local saves and anonymous leaderboard entries, the compliance burden is minimal.