Quick answer: The most common cause is not using the asynchronous callbacks correctly. Local Storage operations in Construct 3 are async — you must read data inside the “On item get” callback, not immediately after calling “Get item.” Other causes include private browsing clearing storage, exceeded storage quotas, mismatched key names, and failing to serialize complex data as JSON strings.
Here is how to fix Construct 3 Local Storage not saving data. You set up the Local Storage plugin, saved the player’s score and progress, and it seemed to work. But when you reopen the game, the data is gone. Or worse, the data never loads correctly in the first place — your “Get item” action returns empty values even though you just saved them moments ago. This is one of the most frustrating issues in Construct 3 because the code looks correct but the timing is wrong.
The Symptom
You use the Local Storage plugin to save game data. You call “Set item” to save a value (like a high score or player position), and then “Get item” to read it back. But the retrieved value is empty, shows 0, or shows the default value instead of what you saved. Alternatively, the data saves correctly during a session but is gone when you close and reopen the game.
In some cases, saving and loading works on desktop Chrome but fails on mobile Safari or in an exported app. The data might save successfully once but then fail silently on subsequent attempts. No error messages appear unless you specifically check for storage errors.
You might also encounter a scenario where data saves correctly for string values like names but fails for numbers, or where saving a simple value works but saving an array or dictionary produces unexpected results.
What Causes This
There are six common causes:
1. Not waiting for async callbacks. This is the cause in roughly 80% of cases. The Local Storage plugin in Construct 3 is asynchronous. When you call “Get item,” the data is not available on the next line — it becomes available when the “On item get” callback triggers. If you try to read LocalStorage.ItemValue outside of this callback, you get stale or empty data.
2. Private browsing clears storage. In private/incognito mode, most browsers either disable localStorage entirely or treat it as session-only storage that is wiped when the window closes. Safari on iOS is especially aggressive — it sets a storage quota of 0 bytes in private mode, causing all write operations to throw a QuotaExceededError.
3. Storage quota exceeded. Browsers impose limits on localStorage (typically 5-10 MB per origin). If your game stores large amounts of data — level maps, replay data, base64-encoded images — you may hit this limit. Once the quota is reached, subsequent “Set item” calls fail silently unless you listen for the error callback.
4. Key naming mismatches. Local Storage keys are case-sensitive strings. If you save with the key “HighScore” and try to load with “highscore” or “high_score,” the get operation will not find the item. This is especially common when keys are constructed dynamically from variables.
5. Not checking if an item exists. If you call “Get item” for a key that was never set (for example, on the first run of the game), the callback will fire but the value will be empty. If your code does not handle this case, it may overwrite defaults with empty values or display incorrect data.
6. Complex data not serialized as JSON. Local Storage only stores strings. If you try to save a Dictionary, Array, or any complex data structure directly, it will be converted to a meaningless string like “[object Object]”. You must serialize complex data to a JSON string before saving and parse it back when loading.
The Fix
Step 1: Use async callbacks correctly. Always read data inside the “On item get” trigger:
// WRONG: reading value immediately after Get item
Condition: System > On start of layout
Action: LocalStorage > Get item "highscore"
Action: Set highScore to LocalStorage.ItemValue // BUG: value not ready yet!
// CORRECT: read value in the callback trigger
Condition: System > On start of layout
Action: LocalStorage > Get item "highscore"
// Separate event:
Condition: LocalStorage > On item get "highscore"
Action: Set highScore to int(LocalStorage.ItemValue)
Action: ScoreText > Set text to "High Score: " & highScore
Step 2: Handle storage errors and private browsing. Add error handling to detect when storage is unavailable:
// Check if Local Storage is available
Condition: System > On start of layout
Action: LocalStorage > Check item exists "test_write"
// If the check triggers an error, storage is likely unavailable
Condition: LocalStorage > On error
Action: Set storageAvailable to 0
Action: WarningText > Set text to "Save data unavailable (private browsing?)"
// Only save when storage is available
Condition: Function > On "SaveGame"
Condition: System > storageAvailable = 1
Action: LocalStorage > Set item "highscore" to highScore
Action: LocalStorage > Set item "level" to currentLevel
Step 3: Use consistent key names. Define key names as global constants or use a naming convention to prevent mismatches:
// Define key names as global text variables
// Global variables:
// KEY_HIGHSCORE = "bugnet_highscore"
// KEY_LEVEL = "bugnet_level"
// KEY_SETTINGS = "bugnet_settings"
// Use the constants everywhere
Action: LocalStorage > Set item KEY_HIGHSCORE to highScore
// ...
Condition: LocalStorage > On item get KEY_HIGHSCORE
Action: Set highScore to int(LocalStorage.ItemValue)
// Prefix keys with your game name to avoid collisions
// with other games on the same domain
Step 4: Check if an item exists before reading. Handle the first-run case where no saved data exists yet:
// Check for existing save data on startup
Condition: System > On start of layout
Action: LocalStorage > Check item exists "highscore"
Condition: LocalStorage > On item exists "highscore"
Action: LocalStorage > Get item "highscore"
Condition: LocalStorage > On item missing "highscore"
Action: Set highScore to 0
Action: LocalStorage > Set item "highscore" to 0
Step 5: Serialize complex data as JSON. Use the Dictionary or Array object’s JSON features, or use JavaScript:
// Saving a Dictionary as JSON to Local Storage
Condition: Function > On "SaveInventory"
Action: LocalStorage > Set item "inventory" to Inventory.AsJSON
// Loading a Dictionary from JSON
Condition: LocalStorage > On item get "inventory"
Action: Inventory > Load from JSON LocalStorage.ItemValue
// For Array objects:
Action: LocalStorage > Set item "levels" to Array.AsJSON
// ...
Condition: LocalStorage > On item get "levels"
Action: Array > Load from JSON LocalStorage.ItemValue
// For custom data via JavaScript:
// Saving:
const saveData = JSON.stringify({
health: runtime.globalVars.health,
posX: runtime.globalVars.playerX,
posY: runtime.globalVars.playerY,
items: runtime.globalVars.itemList.split(",")
});
localStorage.setItem("gamesave", saveData);
// Loading:
const raw = localStorage.getItem("gamesave");
if (raw) {
const data = JSON.parse(raw);
runtime.globalVars.health = data.health;
runtime.globalVars.playerX = data.posX;
runtime.globalVars.playerY = data.posY;
}
Step 6: Handle quota errors gracefully. Wrap save operations with error handling to detect when storage is full:
// Listen for storage errors on every save
Condition: LocalStorage > On item set "highscore"
Action: Browser > Log "Highscore saved successfully"
Condition: LocalStorage > On error
Action: WarningText > Set text to "Save failed: storage full or unavailable"
Action: Browser > Log "Storage error: " & LocalStorage.ErrorMessage
Why This Works
Each fix addresses a different failure point in the storage pipeline:
Async callbacks align your code with how browser storage actually works. Under the hood, Construct 3’s Local Storage plugin uses IndexedDB (not the browser’s native localStorage API). IndexedDB operations are asynchronous by design — they return results via callbacks, not synchronously. The “On item get” trigger fires when the IndexedDB transaction completes, which may be several frames after the “Get item” action was called.
Error handling for private browsing catches the cases where the browser actively blocks or limits storage. By detecting this early, you can show a warning to the player instead of silently losing their progress. Some developers fall back to in-memory storage for the session, which at least preserves data until the browser tab is closed.
Consistent key names eliminate the most subtle cause of data loss — the save succeeds but the load uses a different key and finds nothing. Using global constants for key names is a pattern borrowed from database column name management, and it works equally well here.
Existence checks handle the cold start problem. On the very first run, there is no saved data. If your loading code does not handle this case, it may set game variables to empty or zero, overwriting the defaults you set in the editor.
JSON serialization converts structured data to a format that survives string storage. Without it, a Dictionary saved to Local Storage becomes the string “[object Object]” and all the actual data is lost. JSON round-tripping preserves the structure, types, and values of your data.
"The async issue catches everyone exactly once. After you learn that Local Storage callbacks are not instant, you never make the mistake again. But that first time costs you an afternoon."
Related Issues
If your game data saves correctly but the exported build cannot load it, see exported game not running or blank screen for issues related to export configuration. If you need to save data that is larger than the storage quota allows, consider using the AJAX plugin to save to a server backend instead. And if your save system works but performance drops when saving large amounts of data, check our guide on low FPS and performance lag for tips on deferring heavy operations.
Use the callback. Always use the callback.