Guide

GuideAssetsOffline-capable asset loading

Offline-capable asset loading

Warm a persistent cache ahead of time, and keep the application working - and failing honestly - when the network is gone.

Advanced~4 min read

What you'll learn

  • warm a persistent cache with loader.cacheSource before the connection is gone
  • tell what the environment provides apart from what the application allows
  • let a missing asset fail as a cache miss instead of a timed-out fetch

Before you start

Offline-capable asset loading

A cache makes a second session faster. It only makes an application work offline if two more things are true: the assets were put there before the connection went away, and the loader refuses to reach for the network once it has.

Both are explicit. Nothing here happens by itself.

The shape of it

import { Application, AssetCache, ConnectivityPolicyResolver, IndexedDbStore, type LoaderOptions } from '@codexo/exojs';

const loader: LoaderOptions = {
  basePath: '/assets/',
  cache: new AssetCache({
    stores: new IndexedDbStore('my-game'),
    policy: new ConnectivityPolicyResolver(),
  }),
};

const app = new Application({ loader });

app.connectivity is the application’s own, created for you. The resolver holds no connectivity of its own: it is asked, once per acquisition, which policy that acquisition runs under, and reads the answer from a snapshot the loader hands it.

That is what makes one AssetCache safe to share between two applications - each acquisition carries its own application’s answer, so they can disagree about whether they may use the network.

Warm the cache while you can

loader.cacheSource(asset) acquires an asset’s source and lets the cache keep it - without building the asset:

examples/guides/offline/warm-sources.ts
await app.loader.cacheSource(Asset.type('json', 'levels/01.json'));
await app.loader.cacheSource(Asset.type('json', 'text/dialogue.json'));

Nothing is constructed, nothing is claimed, and nothing stays in memory. What remains is a cache record, which a later load() of the same asset finds because both derive the same source identity from the same descriptor.

Run it where a delay is acceptable and a connection is likely: a title screen, a settings toggle, the pause before a chapter.

Go offline

app.connectivity.mode = 'offline';

From the next acquisition on, the resolver picks a cache-only policy. That has two consequences, and both are the point:

  • an asset whose source was warmed loads normally - the cache answers, and no request is made;
  • an asset that was never warmed fails immediately with an AssetCacheMissError, instead of waiting out a fetch that cannot succeed.

Streamed media follows the same rule, without the caller doing anything:

examples/guides/offline/offline-load.ts
// Online: the browser streams it from the URL.
// Offline: the same call reads the warmed blob, or misses.
const theme = await this.loader.load(Asset.type('music', 'theme.mp3'));

One descriptor covers all of it: cacheSource warms it, load streams or reads it back, and nothing in the descriptor names a transport.

Streaming reaches the network directly, past the cache and therefore past every policy. So when the network is forbidden, a normally-streamed request stops streaming and takes the acquisition path instead - which is the only way mode = 'offline' can mean what it says. No network-backed source is ever installed on the element.

examples/guides/offline/cache-miss.ts
try {
  const world = await this.loader.load(assets.world);
} catch (error) {
  if (error instanceof AssetCacheMissError) {
    // Not "loading failed" - "this was never cached". A different message,
    // and often a different recovery.
    showNotDownloadedYet();
  }
}

Set mode back to 'auto' to follow the environment again, or to 'online' to keep using the network whatever the host reports.

The two questions, kept apart

Connectivity answers two different questions, and never confuses them:

Means Values
state what the environment appears to provide 'online' | 'offline' | 'unknown'
mode what the application allows 'auto' | 'online' | 'offline'

state is a hint. navigator.onLine === true says the device has a network interface, not that your origin answers - a captive portal, a dead resolver and a firewalled host all report as online. 'unknown' is a real answer for a host that reports nothing, and it counts as permitted: refusing on no evidence would break every environment that stays silent.

mode is your decision, and it wins. 'auto' follows state, which is what makes a dropped connection switch the application over without any code running.

Show it in the UI

The same object drives an offline banner. It is an ordinary runtime service - nothing about it is cache-specific:

examples/guides/offline/connectivity-ui.ts
app.connectivity.onStateChange.add(state => {
  offlineBanner.visible = state === 'offline';
});

app.connectivity.onModeChange.add(mode => {
  downloadButton.enabled = mode !== 'offline';
});

Where media data lives

Media the application owns - warmed, unpacked from a container, or read back from the cache - is persisted as a Blob, not as bytes on the JavaScript heap, which is the difference between caching a large video and running out of memory trying. The element is then pointed at an object URL over that blob, and the URL lives exactly as long as the resource does:

Blob -> URL.createObjectURL -> <audio> / <video>

Releasing the asset revokes it. Revoking earlier would break a seek that leaves the buffered range, because the element re-reads its source.

What this is not

This is a cache policy, not a service worker. The application still has to be loaded from somewhere: Connectivity and cacheSource cover the assets an already-running application needs, and say nothing about the document, the bundle, or a request your own code makes with fetch.