Cross-Device Save Sync for Browser Games
How Cross-Device Sync Works
The sync architecture has three layers. The local layer stores game state in IndexedDB or LocalStorage for instant access. The cloud layer stores the canonical save on a remote server. The sync layer coordinates between them, pushing local changes to the cloud and pulling cloud changes to local storage.
The flow on game start is: load the local save from IndexedDB (instant), start the game with the local state (no loading screen), fetch the cloud save in the background, compare timestamps, and resolve any differences. If the cloud save is newer (the player played on another device since the last session on this device), the game applies the cloud state and notifies the player that their progress was updated. If the local save is newer (this device has unsaved changes from a previous session), the game pushes the local state to the cloud.
The flow during gameplay is: save to IndexedDB on every auto-save interval (every 30-60 seconds), and push to the cloud at a longer interval (every 2-5 minutes) or on significant events. The local save happens instantly. The cloud push happens asynchronously in the background. If the cloud push fails (network outage, server error), queue it for retry. The game never blocks on cloud sync.
The flow on game close is: save to IndexedDB immediately, attempt a cloud push (best-effort, because the page may be unloading), and if the push fails, mark the local save as "pending sync" so the next session pushes it before starting. The visibilitychange event (page becoming hidden) is more reliable than beforeunload for triggering the final sync attempt on mobile devices.
Sync Protocols
Full-state sync is the simplest protocol. Every sync operation sends the entire game state to the server and receives the entire state back. The server compares timestamps and stores whichever is newer. This protocol is easy to implement, easy to debug, and works well for games with small save data (under 500 KB). The downside is bandwidth: syncing a 200 KB save every 2 minutes uses about 6 MB per hour, which is acceptable on Wi-Fi but may concern mobile data users.
Delta sync sends only the fields that changed since the last sync. The client tracks which state fields have been modified since the last successful cloud push, serializes only those fields, and sends a partial update. The server applies the delta to the existing save. Delta sync dramatically reduces bandwidth (a typical delta might be 500 bytes instead of 200 KB) and is the right choice for games with large state and frequent syncs. The tradeoff is complexity: the client must track dirty fields, the server must apply partial updates atomically, and conflicts become harder to resolve because two devices may have changed different fields.
Event sourcing stores a log of game events rather than snapshots of game state. Instead of syncing "the player has 500 gold," the system syncs events like "player collected 50 gold at timestamp X" and "player spent 200 gold at timestamp Y." The current state is reconstructed by replaying all events in order. Event sourcing handles conflicts naturally: events from two devices can be merged by interleaving them chronologically. The downside is storage growth (the event log grows indefinitely unless compacted) and reconstruction cost (replaying thousands of events to build the current state can be slow).
For most web games, full-state sync with timestamp comparison is the right starting point. It covers 95% of real-world sync scenarios, and the implementation can be built in a day. Graduate to delta sync only when save data size or sync frequency creates bandwidth problems.
Conflict Resolution in Practice
Conflicts happen less often than developers expect. The typical player uses one device at a time and sessions do not overlap. Conflicts arise in edge cases: the player's phone lost connectivity and accumulated offline progress while they also played on their laptop, or they forgot to close a tab on one device and opened the game on another. Despite being rare, conflicts must be handled gracefully because the alternative is data loss, which destroys player trust.
The simplest conflict detection compares timestamps. Each save (local and cloud) stores a lastModified timestamp. When the client fetches the cloud save on startup, it compares the cloud timestamp to the local timestamp. If they are identical, no conflict exists. If the cloud is newer, apply it. If the local is newer, push it. If both have been modified since the last sync (both timestamps are newer than the last known sync point), a conflict exists.
Version vectors (also called vector clocks) provide more precise conflict detection than timestamps. Each device maintains a counter that increments on every save. The version vector is an object mapping device IDs to their save counters: {"desktop": 42, "phone": 17}. A save is newer than another if all its counters are greater than or equal to the other's counters. If some counters are greater and some are smaller, the saves are concurrent (a true conflict). This approach avoids the pitfalls of timestamp comparison (clock skew between devices, timezone issues) at the cost of more complex bookkeeping.
When a conflict is detected, the resolution strategy determines the outcome. For games where data loss is acceptable (casual games, daily challenges), last-write-wins is fine. For games where players invest significant time, prompt the player: display both saves with metadata (timestamp, level, play time, score) and let them choose. For games with separable state components (progress is separate from inventory, which is separate from settings), merge non-conflicting fields and prompt only for fields that genuinely conflict.
Real-Time Databases for Sync
Firebase Realtime Database and Firestore provide built-in sync that handles most of the complexity described above. When the game writes to a Firebase database path, the SDK automatically persists the data locally, syncs to the server when online, queues writes when offline, and resolves conflicts using last-write-wins at the field level. The game subscribes to database changes with a listener, and the SDK notifies the game whenever the data changes (including changes made from other devices).
Firebase's offline persistence caches the database locally in IndexedDB. When the game starts offline, it reads from the local cache. When connectivity resumes, it syncs pending writes and receives any updates from other devices. This offline-first behavior is built into the SDK and requires only a single line of configuration to enable: firebase.firestore().enablePersistence().
Supabase Realtime provides a similar capability through PostgreSQL's LISTEN/NOTIFY mechanism and WebSocket connections. The game subscribes to changes on a specific row (the player's save record), and Supabase pushes updates in real time. Offline persistence is not built into Supabase's client SDK, so you need to implement local caching with IndexedDB separately, but the real-time sync channel simplifies the online portion of the protocol.
For games that do not need real-time sync (which is most games, since players rarely play the same save on two devices simultaneously), a simple REST API with manual sync on game start and periodic background pushes is sufficient and avoids the complexity of maintaining WebSocket connections.
Handling Network Failures
Network failures are normal, not exceptional. Mobile devices move between Wi-Fi and cellular networks, enter tunnels, lose signal in buildings, and have intermittent connectivity. A sync system that crashes, loses data, or shows error messages during network failures provides a poor player experience.
The queue-and-retry pattern handles failures gracefully. When a cloud push fails, add the save data to a persistent queue (stored in IndexedDB alongside the game save). On the next sync interval, check the queue and retry pending pushes. Use exponential backoff (wait 2 seconds after the first failure, 4 seconds after the second, 8 seconds after the third, up to a maximum of 5 minutes) to avoid hammering a struggling server. Clear the queue only when the server confirms receipt.
Detect network state using navigator.onLine and the online/offline events. When the browser is offline, skip sync attempts entirely (they will fail immediately and waste battery). When the browser comes back online, trigger an immediate sync to push any queued saves. Note that navigator.onLine is not perfectly reliable: it can report online when the connection is too slow for practical use, and it may lag behind actual connectivity changes. Treat it as a hint, not a guarantee, and always handle sync failures regardless of the reported online state.
Testing Cross-Device Sync
Test the following scenarios manually before launch: start on desktop, play, close tab, open on phone, verify progress matches. Play offline on phone, accumulate progress, then open on desktop, verify the phone's progress appears after sync. Open the game on both devices simultaneously, play on both, close both, open on one, verify no data is lost. Clear browser data on one device, sign in again, verify cloud save restores progress. Simulate network failure mid-sync (using browser dev tools' network throttling or offline mode), verify the game handles it gracefully.
Automated testing for sync is harder because it involves multiple clients, timing dependencies, and real network operations. Integration tests that spin up a test server, run two client instances with scripted actions, and verify the final state on both clients catch the most critical bugs. Mock the network layer in unit tests to simulate failures, timeouts, and conflict scenarios without real network calls.
Cross-device sync requires local saves for speed, cloud saves for durability, and a sync protocol that handles offline changes and conflicts. Use full-state sync with timestamp comparison for simplicity, implement queue-and-retry for network failures, and consider Firebase or Supabase for built-in offline-first sync. Test with real multi-device scenarios before launch.