Prefetching Tiles Along a Camera Path
This page warms the tile cache ahead of a camera that is about to move — extrapolating the path, ranking candidate tiles by when they will be needed, issuing those requests at low priority so they never delay a visible tile, and measuring whether the prefetch actually helped rather than just consumed bandwidth.
Why you hit this
Tile streaming is reactive by default: the client works out what it needs from where the camera is, requests it, and shows a hole until it arrives. For a stationary camera that is fine. For a guided flythrough, a scripted tour, a vehicle following a route or a user dragging across a city, it produces a permanent lag — the camera is always a second ahead of the data.
Prefetching removes that lag when it predicts well and wastes bandwidth when it does not, so the whole problem is the ranking and the priority. A prefetch that competes with visible tiles makes the experience worse than no prefetch at all.
Prerequisites
- A CesiumJS viewer with a loaded tileset, and the transport already sorted — see HTTP/2 and connection limits for tile streaming.
- Immutable tile URLs, so a prefetched tile is still valid when it is needed: versioning tilesets with immutable prefixes.
- Either a known route, or camera motion smooth enough to extrapolate.
Step-by-Step
1. Predict where the camera will be
export class CameraPathPredictor {
constructor({ historySize = 12, horizonSeconds = 4.0 } = {}) {
this.history = [];
this.historySize = historySize;
this.horizonSeconds = horizonSeconds;
}
observe(camera, nowMs = performance.now()) {
const p = camera.positionWC;
this.history.push({ t: nowMs, x: p.x, y: p.y, z: p.z,
dirX: camera.directionWC.x, dirY: camera.directionWC.y,
dirZ: camera.directionWC.z });
if (this.history.length > this.historySize) this.history.shift();
}
velocity() {
if (this.history.length < 3) return null;
const a = this.history[0];
const b = this.history[this.history.length - 1];
const dt = (b.t - a.t) / 1000;
if (dt < 0.05) return null;
return { vx: (b.x - a.x) / dt, vy: (b.y - a.y) / dt, vz: (b.z - a.z) / dt, dt };
}
/** Sample future positions; returns [] when the camera is effectively still. */
predict({ samples = 6 } = {}) {
const v = this.velocity();
if (!v) return [];
const speed = Math.hypot(v.vx, v.vy, v.vz);
if (speed < 2.0) return []; // under 2 m/s: reactive loading is fine
const last = this.history[this.history.length - 1];
const out = [];
for (let i = 1; i <= samples; i++) {
const dt = (this.horizonSeconds * i) / samples;
out.push({
seconds: Number(dt.toFixed(2)),
x: last.x + v.vx * dt,
y: last.y + v.vy * dt,
z: last.z + v.vz * dt,
confidence: Number(Math.max(0, 1 - dt / (this.horizonSeconds * 1.5)).toFixed(3)),
});
}
return { speed: Number(speed.toFixed(1)), samples: out };
}
}
Linear extrapolation from a short history is the right model, and it is tempting to do better. A quadratic fit that accounts for acceleration predicts a braking camera well and overshoots wildly on a camera that changes direction, which is the common case in interactive use — so the extra term makes the average prediction worse.
The speed < 2.0 early exit matters more than the prediction itself. A stationary or slowly panning camera does not need prefetching, and prefetching for it burns bandwidth that the reactive loader could use. Most sessions are mostly stationary.
Confidence decaying with the horizon is what step 3 uses to decide how much to spend: a tile needed in four seconds is worth requesting only if there is spare capacity now.
2. Turn predicted positions into candidate tiles
export function candidateTiles(tileset, predicted, { maxCandidates = 80,
sseBudget = 16 } = {}) {
if (!predicted.samples) return [];
const scene = tileset._tilesetRoot?._scene ?? null;
const candidates = new Map();
const walk = (tile, sample, depth) => {
if (depth > 24) return;
const bv = tile.boundingSphere;
if (!bv) return;
const dx = bv.center.x - sample.x;
const dy = bv.center.y - sample.y;
const dz = bv.center.z - sample.z;
const distance = Math.max(Math.hypot(dx, dy, dz) - bv.radius, 1.0);
// Screen-space error at the predicted position decides whether this tile is needed.
const sse = (tile.geometricError * 1080) / (distance * 2 * Math.tan(Math.PI / 6));
if (tile.geometricError > 0 && sse > sseBudget && tile.children?.length) {
for (const child of tile.children) walk(child, sample, depth + 1);
return;
}
if (!tile.contentAvailable && tile.contentUnloaded !== false) {
const key = tile._contentResource?.url ?? String(tile._header?.content?.uri ?? '');
if (!key) return;
const existing = candidates.get(key);
const score = sample.confidence / (1 + sample.seconds);
if (!existing || score > existing.score) {
candidates.set(key, { tile, key, score,
neededInSeconds: sample.seconds,
distance: Math.round(distance),
confidence: sample.confidence });
}
}
};
for (const sample of predicted.samples) walk(tileset.root, sample, 0);
return [...candidates.values()]
.sort((a, b) => b.score - a.score)
.slice(0, maxCandidates);
}
Walking the tree against each predicted position, with the same screen-space error test the renderer uses, is what makes the candidate set correct rather than approximate. A naive prefetch that requests everything within a radius pulls tiles at every level, most of which will never be selected, and the wasted bandwidth is the reason many prefetch implementations are abandoned.
Scoring by confidence / (1 + seconds) puts near-term, high-confidence tiles first, which is the ordering that matters when the budget in step 3 truncates the list. Deduplicating by URL across samples keeps a tile needed at every point along the path from appearing six times.
3. Spend only spare bandwidth
export class PrefetchBudget {
constructor({ maxInflight = 4, maxBytesPerSecond = 2_000_000,
minVisibleHeadroom = 4 } = {}) {
this.maxInflight = maxInflight;
this.maxBytesPerSecond = maxBytesPerSecond;
this.minVisibleHeadroom = minVisibleHeadroom;
this.inflight = new Map();
this.window = [];
}
bytesInLastSecond(nowMs = performance.now()) {
this.window = this.window.filter((e) => nowMs - e.t < 1000);
return this.window.reduce((s, e) => s + e.bytes, 0);
}
/** The visible loader must always have room; prefetch takes what is left. */
canIssue({ visibleRequestsInflight, schedulerCapacity, nowMs = performance.now() }) {
const headroom = schedulerCapacity - visibleRequestsInflight - this.inflight.size;
if (headroom < this.minVisibleHeadroom) {
return { ok: false, reason: `only ${headroom} slots free; reserving for visible tiles` };
}
if (this.inflight.size >= this.maxInflight) {
return { ok: false, reason: `${this.inflight.size} prefetches already in flight` };
}
const used = this.bytesInLastSecond(nowMs);
if (used >= this.maxBytesPerSecond) {
return { ok: false, reason: `${Math.round(used / 1024)} KB/s already spent on prefetch` };
}
return { ok: true, headroom, bytesUsed: used };
}
record(key, bytes, nowMs = performance.now()) {
this.window.push({ t: nowMs, bytes });
this.inflight.delete(key);
}
}
Reserving slots for the visible loader is the single rule that makes prefetching safe. A prefetch that fills the request scheduler starves the tiles the camera is looking at now, and the user sees a worse experience than with no prefetch — holes in front of them while bandwidth goes to geometry they may never reach.
The byte-rate cap is the second guard, and it is there for metered connections and for fairness with everything else the page is doing. Two megabytes per second of speculative traffic is generous on broadband and unacceptable on mobile; deriving it from the measured throughput rather than hard-coding it is the refinement worth making.
4. Issue the requests at low priority
export class TilePrefetcher {
constructor(tileset, { predictor, budget, cacheName = 'tile-prefetch' } = {}) {
this.tileset = tileset;
this.predictor = predictor ?? new CameraPathPredictor();
this.budget = budget ?? new PrefetchBudget();
this.cacheName = cacheName;
this.stats = { issued: 0, completed: 0, bytes: 0, skipped: 0, hits: 0, misses: 0 };
this.prefetched = new Map(); // url -> { at, bytes }
}
async tick(camera, { visibleRequestsInflight = 0, schedulerCapacity = 18 } = {}) {
this.predictor.observe(camera);
const predicted = this.predictor.predict();
if (!predicted.samples) return { issued: 0, reason: 'camera is still' };
const candidates = candidateTiles(this.tileset, predicted);
let issued = 0;
for (const candidate of candidates) {
const gate = this.budget.canIssue({ visibleRequestsInflight, schedulerCapacity });
if (!gate.ok) { this.stats.skipped += candidates.length - issued; break; }
if (this.prefetched.has(candidate.key)) continue;
this.budget.inflight.set(candidate.key, performance.now());
this.#fetchLowPriority(candidate);
issued += 1;
}
return { issued, considered: candidates.length, speed: predicted.speed };
}
async #fetchLowPriority(candidate) {
const url = candidate.key;
try {
const res = await fetch(url, {
priority: 'low', // Fetch Priority: deprioritised at the transport
cache: 'force-cache', // reuse anything already stored
keepalive: false,
});
if (!res.ok) throw new Error(`status ${res.status}`);
const buf = await res.arrayBuffer();
this.stats.issued += 1;
this.stats.completed += 1;
this.stats.bytes += buf.byteLength;
this.prefetched.set(url, { at: performance.now(), bytes: buf.byteLength });
this.budget.record(url, buf.byteLength);
if ('caches' in self) {
const cache = await caches.open(this.cacheName);
await cache.put(url, new Response(buf, { headers: res.headers }));
}
} catch (err) {
this.budget.inflight.delete(url);
this.stats.issued += 1;
}
}
}
priority: 'low' on the Fetch API is what keeps a prefetch from competing at the transport layer: the browser deprioritises the HTTP/2 stream, so a visible tile requested afterwards still gets bandwidth first. Without it, the request scheduler’s own priorities are invisible to the network stack and a prefetch issued a moment earlier wins.
cache: 'force-cache' avoids re-fetching something already in the HTTP cache, which happens constantly when a camera moves back and forth over the same area.
Writing into the Cache Storage API is the part that makes the prefetch usable by the tileset loader. A bare fetch populates the HTTP cache only if the response headers permit it, and an immutable one-year Cache-Control does — which is why immutable prefixes are a prerequisite rather than a nicety. Where they are absent, the explicit cache write is the only mechanism that works.
5. Measure whether it helped
export function prefetchEffectiveness(prefetcher, { windowMs = 60_000 } = {}) {
const now = performance.now();
const entries = performance.getEntriesByType('resource')
.filter((e) => e.name.includes('/content/') && now - e.startTime < windowMs);
let hits = 0, misses = 0, hitBytes = 0, wasteBytes = 0;
const usedUrls = new Set(entries.map((e) => e.name));
for (const e of entries) {
const wasPrefetched = prefetcher.prefetched.has(e.name);
// A cache hit shows transferSize near zero with a non-zero decoded size.
const servedFromCache = e.transferSize === 0 && e.decodedBodySize > 0;
if (wasPrefetched && servedFromCache) { hits += 1; hitBytes += e.decodedBodySize; }
else if (!servedFromCache) misses += 1;
}
for (const [url, info] of prefetcher.prefetched) {
if (!usedUrls.has(url)) wasteBytes += info.bytes;
}
const total = hits + misses;
return {
windowSeconds: windowMs / 1000,
tilesRendered: total,
prefetchHits: hits,
hitRate: total ? Number((hits / total).toFixed(3)) : 0,
hitMB: Number((hitBytes / 1e6).toFixed(2)),
wastedMB: Number((wasteBytes / 1e6).toFixed(2)),
efficiency: hitBytes + wasteBytes
? Number((hitBytes / (hitBytes + wasteBytes)).toFixed(3)) : 0,
verdict: total === 0 ? 'no data'
: hits / total > 0.4 ? 'prefetch is earning its bandwidth'
: hitBytes / Math.max(hitBytes + wasteBytes, 1) < 0.25
? 'mostly waste — shorten the horizon or raise the speed threshold'
: 'marginal',
};
}
Two numbers decide whether prefetching is worth keeping. The hit rate says how much of what the camera needed was already there, and the efficiency says how much of what was fetched got used. A high hit rate with low efficiency means the prediction is too generous — it fetches a wide cone and some of it lands — and shortening the horizon fixes it.
Below about 25% efficiency, prefetching is consuming more bandwidth than it saves and should be turned off for that motion pattern. That is a measurement, not a guess, and it is the reason this function exists rather than a “prefetching is enabled” flag.
6. Handle the known-route case, which is much easier
export async function prefetchAlongRoute(tileset, routePositions,
{ lookaheadSeconds = 8, speedMps = 14,
budget = new PrefetchBudget({ maxInflight: 6 }) } = {}) {
/** A scripted tour or vehicle route: the path is known, so prediction is exact. */
const spacing = speedMps * 1.0; // one sample per second of travel
const samples = [];
let carried = 0;
for (let i = 1; i < routePositions.length; i++) {
const a = routePositions[i - 1];
const b = routePositions[i];
const segLen = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
let travelled = carried;
while (travelled < segLen) {
const f = travelled / segLen;
samples.push({
x: a.x + (b.x - a.x) * f,
y: a.y + (b.y - a.y) * f,
z: a.z + (b.z - a.z) * f,
seconds: samples.length,
confidence: 1.0, // the route is known
});
travelled += spacing;
}
carried = travelled - segLen;
}
const ordered = [];
for (const sample of samples.slice(0, lookaheadSeconds)) {
ordered.push(...candidateTiles(tileset, { samples: [sample] }, { maxCandidates: 40 }));
}
const unique = new Map();
for (const c of ordered) if (!unique.has(c.key)) unique.set(c.key, c);
return { samples: samples.length, tiles: unique.size,
estimatedMB: Number((unique.size * 0.9).toFixed(1)) };
}
A known route changes the economics completely: confidence is 1.0, efficiency approaches 1.0, and the horizon can extend to tens of seconds because there is no prediction error to decay. For a scripted tour the right approach is to prefetch the whole route before starting playback, which turns a stuttering flythrough into a smooth one at the cost of a loading bar.
The interactive case is the hard one, and it is worth recognising which case you are in before tuning anything.
Expected Output & Verification
{ speed: 18.4, samples: [ { seconds: 0.67, confidence: 0.889, … }, … ] }
{ issued: 4, considered: 62, speed: 18.4 }
{
windowSeconds: 60,
tilesRendered: 418,
prefetchHits: 254,
hitRate: 0.608,
hitMB: 22.9,
wastedMB: 25.8,
efficiency: 0.47,
verdict: 'prefetch is earning its bandwidth'
}
{ samples: 112, tiles: 486, estimatedMB: 437.4 }
A 61% hit rate with 47% efficiency on interactive panning is a realistic good result — roughly half the speculative bandwidth is wasted and the half that lands removes most of the visible lag. Whether that trade is acceptable depends on the connection, which is why the budget is configurable rather than fixed.
Verify that prefetching did not slow down the visible tiles, which is the failure mode that matters:
export async function starvationCheck(loadSession, { runs = 2 } = {}) {
const results = [];
for (const prefetchOn of [false, true]) {
performance.clearResourceTimings();
window.__prefetchEnabled = prefetchOn;
const t0 = performance.now();
await loadSession(); // identical camera path both times
const entries = performance.getEntriesByType('resource')
.filter((e) => e.name.includes('/content/'));
const visible = entries.filter((e) => !window.__prefetcher?.prefetched.has(e.name));
const lat = visible.map((e) => e.responseEnd - e.startTime).sort((a, b) => a - b);
results.push({
prefetch: prefetchOn,
visibleTiles: visible.length,
medianLatencyMs: Math.round(lat[Math.floor(lat.length / 2)] ?? 0),
p95LatencyMs: Math.round(lat[Math.floor(lat.length * 0.95)] ?? 0),
wallSeconds: Number(((performance.now() - t0) / 1000).toFixed(2)),
bytesMB: Number((entries.reduce((s, e) => s + (e.encodedBodySize || 0), 0) / 1e6)
.toFixed(1)),
});
}
const [off, on] = results;
return {
results,
visibleLatencyRegressionMs: on.p95LatencyMs - off.p95LatencyMs,
extraBandwidthMB: Number((on.bytesMB - off.bytesMB).toFixed(1)),
safe: on.p95LatencyMs <= off.p95LatencyMs * 1.1,
};
}
The p95 latency of visible tiles must not rise when prefetching is enabled. A regression there means the budget’s headroom reservation is too small or priority: 'low' is not taking effect, and it is the one result that should cause prefetching to be switched off rather than tuned.
Then verify the prediction itself, separately from the fetching, so a bad hit rate can be attributed:
export function predictionAccuracy(predictor, camera, { horizonSeconds = 2.0 } = {}) {
const predicted = predictor.predict();
if (!predicted.samples) return { measurable: false };
const target = predicted.samples.find((s) => s.seconds >= horizonSeconds);
if (!target) return { measurable: false };
return new Promise((resolve) => {
setTimeout(() => {
const p = camera.positionWC;
const errorM = Math.hypot(p.x - target.x, p.y - target.y, p.z - target.z);
const travelledM = predicted.speed * target.seconds;
resolve({
measurable: true,
horizonSeconds: target.seconds,
errorM: Math.round(errorM),
travelledM: Math.round(travelledM),
relativeError: Number((errorM / Math.max(travelledM, 1)).toFixed(3)),
good: errorM < travelledM * 0.35,
});
}, target.seconds * 1000);
});
}
A relative error under about 0.35 is what a useful hit rate needs. Above that the camera is not moving predictably and the speed threshold should be raised so prefetching simply does not engage.
Performance Notes
- Prefetch at 2–4 concurrent requests, not more. The visible loader needs the rest of the scheduler’s capacity.
- A 3–4 second horizon is the sweet spot for interactive use. Longer horizons decay in accuracy faster than they gain in warning time.
- Tick at 4–10 Hz, not per frame. The prediction does not change meaningfully in 16 ms, and the tree walk is not free.
- The tree walk costs 1–4 ms per predicted sample on a city tileset. Six samples per tick at 5 Hz is about 6% of one core.
- Cache Storage writes are asynchronous and cheap but the quota is finite; evict prefetched entries older than a few minutes.
- Disable prefetching on metered connections.
navigator.connection.saveDatais the signal, and honouring it is the difference between a helpful feature and a complaint. - Prefetching cannot fix a transport problem. On HTTP/1.1 there are no spare slots to prefetch into; fix that first.
Common Errors
Visible tiles got slower. Prefetch is starving the visible loader. Raise minVisibleHeadroom and confirm priority: 'low' is supported and applied.
Hit rate near zero despite good predictions. The prefetched responses are not cacheable, so the tileset loader re-fetches. Check Cache-Control on tile responses, or write to Cache Storage explicitly.
Bandwidth doubled with no improvement. The candidate set is not using the screen-space error test, so it is fetching levels that will never be selected.
Prefetch never issues anything. The speed threshold is above the camera’s actual speed, or velocity() is returning null because the history is being cleared each frame.
Memory grows over a long session. The prefetched map and Cache Storage both grow without bound. Evict by age.
Prefetched tiles are stale after a deploy. The tile URLs are mutable. Immutable prefixes make a prefetched tile valid indefinitely.
Hit rate is high but frames still stutter. The bottleneck is decode and GPU upload, not the network. Prefetching does not help with that; smaller tiles and fewer draw calls do.
Frequently Asked Questions
Should prefetching be on by default?
For known routes, yes. For interactive use, on unmetered connections with measured efficiency above roughly 30%, yes — otherwise it is a setting rather than a default.
Can the tileset loader be told about prefetched content directly?
Not through a public API in CesiumJS; the practical mechanism is the HTTP cache or Cache Storage, which the loader’s own fetch then hits. That is why cacheability is a prerequisite.
Does this work with implicit tiling?
Yes, and slightly better: subtree availability is itself prefetchable, and warming the subtree files ahead of the camera removes a serialised round trip before the tiles can even be requested.
Related Guides
- HTTP/2 and Connection Limits for Tile Streaming — the capacity prefetching spends
- Versioning Tilesets with Immutable Prefixes — what makes a prefetched tile stay valid
- Diagnosing Slow First Render of Tilesets — the problem prefetching does not solve
Back to Streaming Sync Patterns.