Need Game Art? Edit Game Trailers Launch Your Dev Site Sell Your Merch
Need Game Art? Sell Your Merch

Saving Game Progress with LocalStorage

Updated July 2026
LocalStorage is the fastest way to add persistent saves to a web game. It stores string data that survives browser restarts, requires no server, no database, and no authentication, and works in every modern browser. A basic save/load system using LocalStorage can be implemented in under 30 lines of JavaScript, making it the go-to choice for indie web games, game jams, and any project where simplicity matters more than scalability.

LocalStorage is a synchronous, key-value storage API built into every browser. It stores strings persistently, scoped to the page's origin (protocol + domain + port). The typical storage limit is 5 MB per origin, which is enough for most 2D games, puzzle games, idle games, and casual experiences. This guide walks through building a complete save system from scratch using LocalStorage, covering the practical details that tutorials usually skip.

Define the Game State Model

The first step is deciding what data to save. Create a plain JavaScript object that captures everything needed to restore the game to its current point. This object should contain only primitive values (numbers, strings, booleans), arrays, and nested plain objects. Do not include class instances, DOM references, canvas contexts, framework objects, or functions.

A typical platformer state model might include: playerX and playerY (position), health (current hit points), maxHealth (if it increases with upgrades), coins (collected currency), inventory (an array of item IDs), currentLevel (which level is active), unlockedLevels (an array of level numbers the player has access to), settings (volume levels, control preferences), totalPlayTime (seconds played across all sessions), and saveVersion (the format version of this save file).

An idle game state model might include: currencies (an object with gold, gems, and other resource counts), buildings (an array of objects, each with a type and level), upgrades (an object mapping upgrade IDs to purchase counts), lastActiveTimestamp (Unix timestamp of the last time the game was open, used to calculate offline progress), statistics (total clicks, total earned, etc.), and saveVersion.

Keep the state model as flat as practical. Deeply nested objects are harder to validate, harder to migrate between versions, and harder to debug when saves go wrong. Two levels of nesting (a top-level object with nested objects or arrays) is the sweet spot for most games. If you find yourself going three or four levels deep, consider restructuring the data.

Serialize and Write to LocalStorage

Converting the game state to a string and storing it is straightforward. Call JSON.stringify() on the state object to produce a JSON string, then call localStorage.setItem(key, jsonString) to store it. The key should be descriptive and unlikely to conflict with other scripts on the same origin: "myGameName_save_slot1" is better than "save".

Always wrap LocalStorage writes in a try-catch block. Writes can fail for three reasons: the storage quota is exceeded (the browser throws a QuotaExceededError), the user is in private browsing mode (Safari throws a QuotaExceededError on any write, Firefox allows writes but clears data when the session ends), or a security policy blocks storage access (the browser throws a SecurityError). Your catch block should notify the player that the save failed and suggest they check their browser settings, rather than silently swallowing the error.

For games that save frequently (auto-save every 30 seconds), the synchronous nature of LocalStorage is a real concern. localStorage.setItem() blocks the main thread until the write is complete. For small saves (under 50 KB), this takes less than a millisecond and is invisible. For larger saves (hundreds of kilobytes), the blocking time can exceed a frame budget, causing a visible stutter. If your save data is growing beyond 100 KB and you auto-save frequently, consider switching to IndexedDB, which writes asynchronously.

Read and Deserialize Saved Data

Loading a save is the reverse: call localStorage.getItem(key) to retrieve the JSON string, then call JSON.parse() to convert it back to a JavaScript object. If the key does not exist (no save has been made yet), getItem returns null, so check for null before parsing. Calling JSON.parse(null) returns null as well, which is convenient, but passing an empty string or corrupted data will throw a SyntaxError.

Validate the parsed object before applying it to the game. At minimum, check that the result is a non-null object, that required fields exist, and that numeric fields are actually numbers. A more thorough validation checks every field against expected types and ranges: is health a positive number? Is currentLevel a valid level identifier? Is inventory an array of known item IDs? Validation catches save corruption, tampering (players who manually edit LocalStorage through browser dev tools), and version mismatches.

Apply saved values to the game state defensively. Instead of replacing the entire game state with the loaded data, merge it field by field, using default values for any missing fields. This approach means that if a save file from an older version is missing a new field, the game uses a sensible default instead of crashing. For example: player.health = saveData.health || 100; uses the saved health if it exists, or defaults to 100 if the field is missing or zero.

Add Save Versioning and Migration

Every save object should include a saveVersion field, starting at 1. When you change the structure of the save data (adding fields, removing fields, renaming fields, changing value types), increment the version number and write a migration function that transforms saves from the previous version to the new one.

Migration functions should be chained: each one upgrades by exactly one version. If a player has a version 1 save and the current game version is 4, the loader runs migration 1-to-2, then 2-to-3, then 3-to-4. This is more maintainable than writing a separate migration for every possible source version. Keep all migration functions in the codebase permanently, never delete them, because players may return to your game months after their last session with a very old save.

A migration function receives the old save object and returns a new one with the updated structure. Common migrations include: adding a new field with a default value (save.newField = save.newField || defaultValue), renaming a field (copy the value to the new key, delete the old key), restructuring data (converting a flat array to an array of objects), and removing deprecated fields (simply delete them). After migration, update the saveVersion field to the current version. Test migrations with real save files from previous versions to catch edge cases, especially around null values, empty arrays, and missing nested objects.

Implement Save Slots and Metadata

Save slots let players maintain multiple game states. Implement them as separate LocalStorage keys: myGame_slot_1, myGame_slot_2, myGame_slot_3. Three slots is standard for single-player games. Each slot stores the full serialized game state. A separate metadata key (myGame_meta) stores lightweight information about each slot, including the timestamp of the last save, the player's current level or area name, total play time, and whether the slot is in use. The save/load screen reads the metadata key to populate slot previews instantly, without parsing the full game state of each slot.

Include a "delete save" function that removes a slot's data and updates the metadata to show the slot as empty. Be cautious with localStorage.clear(), which deletes all LocalStorage data for the entire origin, including data from other scripts or games hosted on the same domain. Use localStorage.removeItem(key) to delete specific saves.

For games that do not need multiple saves, a single auto-save slot is the simplest approach. Store the game state under a single key, overwrite it on every save, and load it automatically on game start. This is the standard pattern for idle games, endless runners, and roguelikes where the concept of separate save files does not apply.

Add Emergency Save on Tab Close

The beforeunload event fires when the user closes the tab, navigates away, or refreshes the page. Listening for this event and performing a save catches most unexpected exits. Because beforeunload handlers must execute quickly (browsers enforce a strict timeout), LocalStorage's synchronous API is actually an advantage here: a localStorage.setItem() call completes within the event handler's execution window, whereas an asynchronous IndexedDB write might be cancelled when the page unloads.

Mobile browsers are less reliable with beforeunload. Safari on iOS does not always fire the event when the user switches apps or the OS kills the browser. Chrome on Android fires the event in most cases but not when the browser process is force-killed. For this reason, beforeunload should be a safety net, not the primary save mechanism. Auto-save at regular intervals is the reliable foundation, and beforeunload captures the most recent changes between the last auto-save and the tab closure.

The visibilitychange event is a useful complement to beforeunload. It fires when the page becomes hidden (the user switches tabs, minimizes the browser, or locks the screen). Saving on visibilitychange catches tab switches on mobile, which is one of the most common ways players leave a web game. Check document.visibilityState === 'hidden' inside the event handler to confirm the page is being hidden rather than shown.

Performance and Limitations

LocalStorage performance is predictable but limited. Reads are fast: getItem typically completes in under 0.1 milliseconds for saves under 1 MB. Writes are slower, especially for large values: setItem with a 500 KB string can take 5-10 milliseconds on desktop browsers and longer on mobile devices. The 5 MB origin limit is a hard constraint that cannot be increased. Attempting to exceed it throws a QuotaExceededError.

For perspective on what fits in 5 MB: a full JSON game state for a complex RPG (including hundreds of items, dozens of quest flags, and a large world state) typically fits in 50-200 KB. An idle game with extensive statistics and building data typically fits in 10-50 KB. A puzzle game with level completion records for 1,000 levels fits in under 10 KB. The 5 MB limit is generous for game state data, and most web games will never approach it. The limit becomes relevant if you try to store binary data (images, audio) or extensive replay data alongside game state.

Data durability is the biggest practical concern. Browsers can clear LocalStorage data without user action. Chrome clears "site data" for origins that have not been visited in a long time if the device is under storage pressure. Safari limits data to 7 days for third-party origins and has historically been aggressive about clearing data. Users can manually clear their browser data at any time. For games where progress is valuable (dozens of hours of play time), relying solely on LocalStorage is risky. Pair it with cloud saves, or at minimum provide an export function that lets players download their save data as a file and import it later.

Key Takeaway

LocalStorage is the right choice for web games with save data under 1 MB and infrequent writes. Use JSON.stringify/parse for serialization, wrap all writes in try-catch, include a version number in every save, and add beforeunload and visibilitychange handlers as emergency save triggers. For larger data or frequent auto-saves, use IndexedDB instead.