Quick answer: On WebGL, Unity stores PlayerPrefs in the browser’s IndexedDB via Emscripten’s virtual filesystem, but the write is asynchronous and requires an explicit PlayerPrefs.Save() call. Safari’s Intelligent Tracking Prevention (ITP) will also clear IndexedDB for cross-origin iframes (such as games hosted on itch.io), making saves disappear silently. For file-based saves via Application.persistentDataPath, you must call FS.syncfs() via a JavaScript plugin after every write to flush data to IndexedDB.
WebGL is the great equaliser for indie games — no download required, playable in any browser, instantly shareable on itch.io or a personal site. It’s also a platform that silently breaks several assumptions Unity developers carry from desktop and mobile development. Chief among them: the assumption that PlayerPrefs just works. On WebGL, saves vanish between sessions with alarming regularity, and the root causes span Unity internals, browser security policies, and hosting context all at once.
How Unity WebGL Saves Data: The Full Stack
On Windows, macOS, and Linux, PlayerPrefs writes to the system registry or a plist file synchronously. On Android and iOS it writes to a shared preferences XML or plist on the device. On WebGL, the entire storage stack is different.
Unity’s WebGL build uses Emscripten to compile C# to WebAssembly. Emscripten provides a virtual POSIX filesystem in memory, called MEMFS, and a layer called IDBFS that can persist MEMFS contents to IndexedDB. PlayerPrefs writes to MEMFS immediately, but the change only reaches IndexedDB when the filesystem is explicitly synchronised. Unity does this sync on application quit — except there is no reliable “application quit” event in a browser. When the user closes the tab or navigates away, Unity’s Application.quitting event may not fire in time for the async sync to complete.
The result: data appears to save (it’s in MEMFS), but it’s gone the next session (IndexedDB was never written).
Fix 1: Call PlayerPrefs.Save() Explicitly and Often
The immediate fix is to stop relying on the automatic save-on-quit and call PlayerPrefs.Save() explicitly at every meaningful save point: after completing a level, after changing a setting, after any event that players would consider “progress.”
public void SaveProgress()
{
PlayerPrefs.SetInt("CurrentLevel", currentLevel);
PlayerPrefs.SetFloat("TotalTime", totalPlayTime);
PlayerPrefs.SetInt("HighScore", highScore);
// On WebGL this triggers the IDBFS sync. On other platforms it's a no-op.
PlayerPrefs.Save();
}
void OnApplicationFocus(bool hasFocus)
{
// Also save when the player alt-tabs or minimises — in the browser
// losing focus is often the last reliable lifecycle event before a tab close.
if (!hasFocus)
SaveProgress();
}
Using OnApplicationFocus(false) as an additional save trigger is a practical safety net on WebGL because the browser fires the page’s visibilitychange event (which Unity proxies to OnApplicationFocus) before a tab is closed, giving the async sync a better chance of completing.
Fix 2: Using FS.syncfs() for File-Based Saves
If you’re writing save files to Application.persistentDataPath rather than using PlayerPrefs, you must manage the IDBFS sync manually via a JavaScript plugin. Unity does not automatically call FS.syncfs() for arbitrary file writes.
// WebGLPersistence.jslib — place in Assets/Plugins/WebGL/
mergeInto(LibraryManager.library, {
SyncFilesystem: function(populate) {
FS.syncfs(populate, function(err) {
if (err) console.error('FS.syncfs error:', err);
});
}
});
// C# side — call these from your save/load manager
using System.Runtime.InteropServices;
using UnityEngine;
public class WebGLPersistence : MonoBehaviour
{
[DllImport("__Internal")]
private static extern void SyncFilesystem(bool populate);
/// Call on startup, before reading any save files
public static void PopulateFromIndexedDB()
{
#if UNITY_WEBGL && !UNITY_EDITOR
SyncFilesystem(true); // true = pull from IndexedDB into MEMFS
#endif
}
/// Call after every save operation
public static void FlushToIndexedDB()
{
#if UNITY_WEBGL && !UNITY_EDITOR
SyncFilesystem(false); // false = push MEMFS into IndexedDB
#endif
}
}
The populate: true call on startup is critical and often missed. Without it, the in-memory filesystem starts empty every session and any subsequent file reads return “file not found” even though the data is sitting in IndexedDB perfectly intact.
Cause 3: Safari’s ITP Clearing Cross-Origin Iframe Storage
Even with correct PlayerPrefs.Save() calls, Safari users on itch.io will often lose save data. The reason is Intelligent Tracking Prevention (ITP), Apple’s cross-site tracking countermeasure built into WebKit.
When your game is embedded in an itch.io page, it runs in an iframe. The iframe’s origin is your game’s itch.io subdomain, but the parent page’s origin is itch.io. Safari treats this as a third-party context and applies storage partitioning and aggressive expiry policies — IndexedDB data may be cleared after 7 days without user interaction, or immediately in Private Browsing mode.
The most reliable mitigation for itch.io games is to host on your own domain and embed from there, keeping the game’s iframe origin first-party. If that’s not an option, display a warning to Safari users that save data may not persist, and consider offering a manual export/import feature (serialise save data to a string the user can copy and paste back).
Localhost vs. Hosted Domain Behaviour
A frequently confusing aspect of WebGL development is that persistence tests run on localhost do not reflect production behaviour. Chrome on localhost treats the page as a secure first-party context and applies almost no storage restrictions. Safari on localhost is more permissive than Safari on a hosted domain with ITP active. Firefox’s total cookie protection partitions storage differently depending on whether the domain is in the blocklist.
Always test persistence by deploying to a real staging URL on the same hosting setup you’ll use for production, across Chrome, Firefox, and Safari. A quick “open, save, close tab, reopen, check values” test in each browser should be part of your pre-release checklist for any WebGL game.
Tab Refresh vs. Full Session: Know the Difference
If you press F5 to refresh the browser tab while testing, the page reloads but the browser process stays alive. IndexedDB data written in the previous session may still be held in the browser’s write cache, making persistence appear to work. A true new session requires closing the tab entirely, waiting a moment, and opening a new tab — or even restarting the browser for the most conservative test. Use the browser’s DevTools (Application tab in Chrome, Storage Inspector in Firefox) to inspect IndexedDB contents directly and verify that your data was actually written rather than just cached.
“On WebGL, a save that works on your machine in Chrome on localhost is the weakest possible evidence that saves work. Ship nothing until you’ve verified persistence on Safari on a hosted domain with the tab fully closed and reopened.”
WebGL saves are solvable, but they require you to think about the browser as an adversary to persistence rather than a neutral platform — once that mental model clicks, the fixes become obvious.