Making Games Work Offline with Service Workers

Updated June 2026
Service workers let web games intercept network requests, cache game assets locally, and serve those assets when no internet connection is available. A well-implemented offline strategy means players can launch your game on a plane, in a subway, or anywhere with spotty coverage and play without interruption. This guide walks through the specific caching patterns that work for games, where the assets are large, the loading order matters, and a single missing file breaks the experience.

Offline support is not just a technical checkbox for PWA compliance. It changes how players relate to your game. A game that works offline feels reliable. It feels like something that belongs on the device rather than something borrowed from a server. Players who know the game works anywhere are more likely to open it during idle moments, and those repeat sessions drive retention numbers that matter for ad revenue and player engagement metrics.

Step 1: Identify Your Critical Assets

Before writing any caching code, make a complete list of every file the game needs to start and run its core gameplay. This is your critical asset list, and every file on it must be cached during the service worker's install phase.

For a typical 2D web game, the critical assets include: the HTML shell page, one or more JavaScript files containing the game engine and logic, CSS stylesheets, the main spritesheet or texture atlas (usually one or two large PNG files), essential sound effects (compressed as MP3 or OGG), the game's font files if it uses custom typography, and any JSON files that define levels, item data, or configuration.

Do not include optional content in the critical list. Expansion levels, bonus sound packs, tutorial videos, and rarely-used assets should be cached later through runtime caching. The goal is to keep the critical pre-cache as small as practical, ideally under 25MB, so the initial service worker installation completes quickly even on slow mobile connections.

Use your browser's DevTools Network panel to identify assets. Load the game, play through the first level, and record every network request. Sort by size to prioritize the heaviest assets. Any request that fails and breaks the game must be on the critical list.

Step 2: Pre-Cache Core Assets on Install

The service worker's install event fires once, when the browser first registers the service worker or detects a new version. This is where you open a named cache and add all critical assets.

Use caches.open('game-v1') to create or access a named cache, then cache.addAll() with your asset list to download and store every file. The addAll method is atomic: if any single file fails to download, the entire install fails and the service worker does not activate. This is actually desirable for games, because a partially cached game is worse than no cache at all, it creates a confusing broken state.

Call self.skipWaiting() at the end of the install handler to activate the new service worker immediately rather than waiting for all existing tabs to close. For games, this is important because players often have only one tab open, and you want the service worker controlling the page as soon as possible.

If your critical asset list is large (over 50 files), consider batching the downloads. Calling addAll with hundreds of URLs simultaneously can cause timeouts on slow connections. Instead, break the list into groups of 20-30 files and add them sequentially with individual cache.add calls wrapped in a loop. This is slower overall but more reliable on constrained networks.

Step 3: Implement Cache-First Fetch for Game Files

The fetch event fires every time the game makes a network request. Your handler decides whether to serve the response from cache or from the network. For game assets, cache-first is almost always the right strategy.

In a cache-first pattern, the service worker checks the cache first with caches.match(event.request). If the asset is found, it returns the cached response immediately, giving the player near-instant loading. If the asset is not in cache, the service worker fetches it from the network and optionally stores the response in the cache for future use.

This pattern works well for games because game assets rarely change between versions. Sprites, audio, and code are static until you deliberately push an update. Serving from cache eliminates network latency entirely, making load times feel native rather than web-based.

Do not cache API responses with a cache-first strategy. Leaderboard data, multiplayer state, authentication tokens, and analytics calls should use a network-first strategy: try the network, and only fall back to cached data if the network fails. This ensures players see current data when online and stale-but-functional data when offline.

Add a URL filter to your fetch handler. Only apply caching logic to requests within your game's scope. External resources like analytics scripts, ad networks, and third-party CDNs should pass through to the network without caching, as their content changes frequently and caching them can cause version conflicts.

Step 4: Add Runtime Caching for Dynamic Content

Not all game content should be pre-cached. Levels beyond the first few, optional sound packs, expansion content, seasonal event assets, and high-resolution alternatives are better cached on demand as the player encounters them.

Runtime caching happens in the fetch handler. When a request misses the cache and the network fetch succeeds, clone the response (responses can only be consumed once) and store the clone in a separate cache. Use a different cache name for runtime-cached content, like 'game-dynamic-v1', to keep it separate from the critical pre-cache. This separation makes cache management easier when you release updates.

Consider adding a "Download for Offline" feature in your game's settings menu. When the player taps this button, the game fetches all optional content and caches it proactively. This gives the player control over bandwidth usage and storage, which is especially important on mobile devices with limited data plans. Show a progress indicator during the download so the player knows it is working.

Set size limits for the runtime cache. Without limits, a game with many levels can accumulate hundreds of megabytes of cached content. Implement a simple eviction policy: when the runtime cache exceeds a threshold (say 100MB), delete the oldest entries. Or use a time-based policy: evict entries that have not been accessed in the past 30 days.

Step 5: Handle Offline Gracefully for Network Features

Many games have features that require network connectivity: multiplayer lobbies, leaderboards, cloud saves, daily challenges, friend lists, and in-game chat. When the player is offline, these features cannot work normally, but they should never crash the game or show raw error messages.

Detect connectivity status using navigator.onLine and the online/offline events on the window object. When the game detects offline status, swap network-dependent UI elements with clear messaging. A leaderboard panel can show "Leaderboards available when connected" with the player's locally cached scores still visible. A multiplayer button can display "Offline, single player only" instead of silently failing.

Queue actions that need the network. If a player achieves a high score offline, save it locally with a "pending sync" flag. When connectivity returns, push the queued scores to the server. The Background Sync API can handle this automatically: register a sync event in the service worker, and the browser will fire it when the device regains connectivity, even if the game tab is closed.

For daily challenges and time-limited content, cache the current challenge data when the player is online. If the challenge expires while offline, show the most recent cached challenge with a note that new challenges will load when connected. This keeps the game playable and engaging even during extended offline periods.

Step 6: Version and Clean Up Caches on Update

Every time you update game assets, increment the version string in your cache names: 'game-v1' becomes 'game-v2'. When the browser detects that the service worker file has changed, it installs the new version, which caches the updated assets under the new cache name.

During the activate event, delete all caches that do not match the current version. Use caches.keys() to list all cache names, filter out the ones that match your current cache names, and call caches.delete() on the rest. This frees up storage from obsolete versions and prevents the player from accidentally loading old assets.

Consider the player's experience during updates. If a player is mid-game when a new service worker installs, the old service worker continues serving the old assets until the page is reloaded. Do not force a reload mid-session. Instead, listen for the controllerchange event and show a subtle notification: "New version available, restart to update." Let the player finish their current session and update at their convenience.

Test the update flow thoroughly. Deploy a new version, verify that the new service worker installs, check that old caches are deleted during activation, and confirm that the next page load serves updated assets. The most common bug in service worker updates is forgetting to change the cache version string, which causes the install event to re-cache identical files without cleaning up the old cache.

Key Takeaway

Offline play in web games requires pre-caching critical assets during service worker installation, serving those assets with a cache-first fetch strategy, runtime-caching additional content as players encounter it, and gracefully degrading network-dependent features. Version your caches on every update and clean up old versions during activation.