Quick answer: The Construct 3 LocalStorage plugin is asynchronous. When you call "Get item", the value is not available immediately — you must wait for the "On item get" callback trigger before reading LocalStorage.ItemValue. If you read the value before the callback fires, you get an empty string. Other causes include mismatched key names, private browsing mode clearing storage on session end, and storage quota limits.
You save the player's score with "Set item" and it seems to work. You reload the page, call "Get item" on the same key, read LocalStorage.ItemValue, and get an empty string. The data is gone. You verified the key name, you double-checked the Set action — but the value simply does not persist. The problem is almost certainly that you are reading the value synchronously after calling Get, when the LocalStorage plugin requires you to wait for its asynchronous callback.
The Symptom
You use the LocalStorage plugin to save game data (scores, settings, progress). The "Set item" action appears to work — no errors are thrown. But when you reload the page or restart the game and use "Get item" to retrieve the data, the value comes back empty or as the default value.
A common variant: the data loads correctly sometimes but not other times. On fast machines, it might work. On slower machines or mobile devices, it fails. This inconsistency is the hallmark of a race condition caused by reading async data synchronously.
Another variant: data saves and loads correctly within a single session (across layout changes) but is lost on page reload. This suggests the Set action is updating an in-memory cache but the write to the underlying IndexedDB (which the LocalStorage plugin uses internally) is failing or being interrupted.
What Causes This
There are five causes:
1. Reading the value before the async callback fires. The LocalStorage plugin's "Get item" action is asynchronous. When you call it, the action returns immediately and the engine continues executing the next events. The actual data retrieval happens in the background. The value only becomes available in LocalStorage.ItemValue when the "On item get" trigger fires. If you read LocalStorage.ItemValue in the same event as the "Get item" action, or in a subsequent event before the callback, you get whatever was in ItemValue previously (often an empty string).
2. Key name mismatch. Storage keys are strings, and they are case-sensitive and whitespace-sensitive. If you save with the key "playerScore" and try to load with "PlayerScore" or "player_score", the get operation returns nothing because no value exists under that key. Trailing spaces are also a common culprit — "score " is not the same as "score".
3. Private browsing mode. In most modern browsers, localStorage and IndexedDB work in private/incognito mode, but all stored data is deleted when the private window is closed. Players using incognito mode will have working save/load within a session, but their data will be gone the next time they open the game. Some older mobile browsers may block storage entirely in private mode, causing Set operations to silently fail.
4. Storage quota exceeded. Browsers impose storage limits (typically 5-10 MB per origin for localStorage, more for IndexedDB). If the player's storage is full — especially on mobile devices with limited space — the Set action will fail. The LocalStorage plugin has an "On item set error" trigger for this, but if you do not handle it, the failure is silent.
5. Storing complex data without serialization. The LocalStorage plugin stores string values only. If you try to store a number, it is converted to a string automatically (which is fine). But if you try to store a Dictionary, Array, or any structured data directly, it will not work as expected. You need to serialize complex data to a JSON string before storing and parse it back when loading.
The Fix
Step 1: Use the async callback pattern correctly. Never read LocalStorage.ItemValue immediately after "Get item". Always use the "On item get" trigger.
// WRONG: Reading value immediately after Get
+ System: On start of layout
-> LocalStorage: Get item "playerScore"
-> Player: Set score to int(LocalStorage.ItemValue)
// ItemValue is still empty here! The Get has not
// completed yet.
// CORRECT: Wait for the callback
+ System: On start of layout
-> LocalStorage: Get item "playerScore"
+ LocalStorage: On item get "playerScore"
-> Player: Set score to int(LocalStorage.ItemValue)
// Now ItemValue contains the actual stored value.
The "On item get" trigger fires once the data has been read from IndexedDB. Only inside this trigger (and its sub-events) is LocalStorage.ItemValue guaranteed to contain the retrieved data.
Step 2: Use consistent key names. Define your storage keys as global constants (or at least in one place) to avoid typos.
// Use global variables as key constants:
// KEY_SCORE (String) = "bugnet_save_score"
// KEY_HEALTH (String) = "bugnet_save_health"
// KEY_LEVEL (String) = "bugnet_save_level"
// Save:
+ System: On function "SaveGame"
-> LocalStorage: Set item KEY_SCORE
to str(Player.score)
-> LocalStorage: Set item KEY_HEALTH
to str(Player.health)
-> LocalStorage: Set item KEY_LEVEL
to str(CurrentLevel)
// Load:
+ System: On start of layout
-> LocalStorage: Get item KEY_SCORE
+ LocalStorage: On item get KEY_SCORE
-> Player: Set score to int(LocalStorage.ItemValue)
-> LocalStorage: Get item KEY_HEALTH
+ LocalStorage: On item get KEY_HEALTH
-> Player: Set health to int(LocalStorage.ItemValue)
-> LocalStorage: Get item KEY_LEVEL
+ LocalStorage: On item get KEY_LEVEL
-> System: Set CurrentLevel
to int(LocalStorage.ItemValue)
Note how the loads are chained: each "On item get" triggers the next "Get item". This ensures values are loaded in sequence, each one available when needed.
Step 3: Handle errors gracefully. Always implement error handlers for storage operations.
// Handle storage errors:
+ LocalStorage: On item set error
-> DebugText: Set text to
"Save failed: " & LocalStorage.ErrorMessage
// Show a player-facing message:
-> StatusText: Set text to
"Could not save. Storage may be full."
+ LocalStorage: On item get error
-> DebugText: Set text to
"Load failed: " & LocalStorage.ErrorMessage
// Use default values as fallback:
-> Player: Set score to 0
-> Player: Set health to 100
// Check if an item exists before reading:
+ System: On start of layout
-> LocalStorage: Check item "playerScore" exists
+ LocalStorage: On item exists
-> LocalStorage: Get item "playerScore"
+ LocalStorage: On item missing
-> Player: Set score to 0
// First time playing, no save data exists.
Step 4: Serialize complex data to JSON. Use the JSON object or a Dictionary's AsJSON expression to store structured data.
// Save complex data using a Dictionary:
// (SaveData is a Dictionary object)
+ System: On function "SaveGame"
-> SaveData: Add key "score"
with value Player.score
-> SaveData: Add key "health"
with value Player.health
-> SaveData: Add key "posX"
with value Player.X
-> SaveData: Add key "posY"
with value Player.Y
-> SaveData: Add key "level"
with value CurrentLevel
-> LocalStorage: Set item "gameState"
to SaveData.AsJSON
// Load complex data:
+ LocalStorage: On item get "gameState"
-> SaveData: Load from JSON string
LocalStorage.ItemValue
-> Player: Set score to SaveData.Get("score")
-> Player: Set health to SaveData.Get("health")
-> Player: Set position to
(SaveData.Get("posX"), SaveData.Get("posY"))
-> System: Set CurrentLevel
to SaveData.Get("level")
Step 5: Handle private browsing and storage availability. Check if storage is available before relying on it.
// At the start of the game, test storage availability:
+ System: On start of layout
-> LocalStorage: Set item "test_write" to "1"
+ LocalStorage: On item set "test_write"
-> System: Set StorageAvailable to true
-> LocalStorage: Delete item "test_write"
+ LocalStorage: On item set error
-> System: Set StorageAvailable to false
-> StatusText: Set text to
"Save/load not available in this browser mode."
// Gate all save/load operations behind the flag:
+ System: On function "SaveGame"
+ System: StorageAvailable = true
-> LocalStorage: Set item "gameState"
to SaveData.AsJSON
Why This Works
The LocalStorage plugin uses IndexedDB internally (not the browser's localStorage API, despite the name). IndexedDB is inherently asynchronous — all read and write operations happen in the background and complete at an unpredictable time. The plugin communicates completion through callback triggers ("On item get", "On item set", etc.).
When you call "Get item", the plugin sends a read request to IndexedDB and immediately returns control to the event sheet. The engine continues executing the next events. At some point later (often within a frame, but not guaranteed), IndexedDB responds with the data, and the plugin fires the "On item get" trigger. Only at this point is LocalStorage.ItemValue populated with the retrieved data.
This is fundamentally different from reading a variable or an instance property, which are synchronous — the value is available immediately. The async model is necessary because IndexedDB may need to read from disk, which is orders of magnitude slower than reading from memory.
The chained callback pattern (where each "On item get" triggers the next "Get item") ensures that values are loaded in a predictable order. Without chaining, you could issue multiple Get calls simultaneously, and their callbacks would fire in an unpredictable order, making it hard to know which value is in ItemValue at any given time.
The JSON serialization approach works because strings are the universal currency of browser storage APIs. By converting your entire game state to a single JSON string, you reduce multiple async operations (one per field) to a single Set/Get pair, which is both simpler and more reliable.
"The number one LocalStorage mistake in Construct 3: reading the value on the same line you requested it. It is async. Wait for the callback. Always wait for the callback."
Related Issues
If your data persists within a session but objects are lost on layout change, see object not found after layout change for global object persistence. For audio settings that save but do not apply on reload, audio not playing on first touch covers the autoplay policy that may block your saved audio preferences from taking effect. If you save collision or gameplay state that does not restore correctly, collision not detected between objects may help with post-load collision issues. And for animation states that do not restore after loading, check animation not changing on trigger for proper state machine patterns.
It is async. Wait for the callback. Every single time.