Measuring Tile Load Times in the Cesium Frame Loop
This page instruments a CesiumJS client so that “the tiles are slow” becomes four numbers — request latency, decode time, GPU upload, and draw — measured separately and adding up to the frame budget. The instrumentation is light enough to leave running in production, which matters because the interesting cases happen on a viewer’s connection and hardware rather than on yours.
Why you hit this
A tile’s journey has four stages with completely different remedies, and every one of them presents to a user as the same symptom. A slow network means prefetching earlier; a slow decode means different compression settings; a slow upload means smaller or fewer buffers; a slow draw means too many draw calls. Choosing between them by inspection is guesswork, and the browser’s default profiler distorts the frame budget it is supposed to be measuring.
The diagnostic framework this feeds is in streaming and runtime diagnostics; this page is about producing the numbers it reads.
Prerequisites
- CesiumJS 1.107+ (earlier versions expose fewer tile events), served over HTTPS so
performancetiming is unthrottled. - A tileset you can reload, and a repeatable camera path — a scripted flight, not a hand-flown one.
- Chrome or Chromium for
PerformanceObserverand the Resource Timing API. Both work in Firefox with minor naming differences. Timing-Allow-Originset on the tile CDN, or cross-origin resource timings return zeros for everything except duration.
Step-by-Step
1. Capture the network half with Resource Timing
The browser already measures every tile request. You only have to collect it.
const netStats = [];
const obs = new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (!/\.(b3dm|glb|pnts|subtree)$/.test(e.name)) continue;
netStats.push({
url: e.name,
wait: e.responseStart - e.requestStart, // server think time
download: e.responseEnd - e.responseStart, // transfer
total: e.duration,
bytes: e.encodedBodySize,
cached: e.transferSize === 0,
});
}
});
obs.observe({ type: 'resource', buffered: true });
function summariseNetwork() {
const live = netStats.filter((s) => !s.cached);
const p = (arr, q) => arr.sort((a, b) => a - b)[Math.floor(arr.length * q)] || 0;
const totals = live.map((s) => s.total);
console.table({
requests: live.length,
cacheHits: netStats.length - live.length,
p50_ms: p(totals, 0.5).toFixed(1),
p95_ms: p(totals, 0.95).toFixed(1),
medianKB: (p(live.map((s) => s.bytes), 0.5) / 1024).toFixed(1),
});
}
Separating wait from download is what distinguishes a CDN miss from a fat tile. A p95 wait of 400 ms with a small download means requests are reaching the origin; a small wait with a long download means the tiles are simply large.
2. Time decode and upload inside the frame loop
CesiumJS raises events as a tile moves through its lifecycle. Timing between them gives decode and upload separately.
const tileset = await Cesium.Cesium3DTileset.fromUrl('/live/tileset.json');
viewer.scene.primitives.add(tileset);
const started = new Map();
const timings = { decode: [], upload: [] };
tileset.tileLoad.addEventListener((tile) => {
const t = started.get(tile._header.content?.uri);
if (t) timings.decode.push(performance.now() - t);
});
tileset.tileVisible.addEventListener((tile) => {
const t = started.get(tile._header.content?.uri);
if (t) {
timings.upload.push(performance.now() - t);
started.delete(tile._header.content?.uri);
}
});
tileset.tileFailed.addEventListener((e) => console.warn('tile failed', e.url, e.message));
Cesium.RequestScheduler.requestCompletedEvent.addEventListener(() => {
// nothing here; the hook exists so the scheduler's queue depth can be sampled
});
tileLoad fires when the content has been parsed and decoded; tileVisible fires on the first frame the tile is actually drawn, which is after the GPU upload. The difference between them is the upload cost, and it is frequently larger than people expect for texture-heavy tiles.
3. Sample the scheduler’s queue depth
Time spent queued is invisible in every per-tile measurement, and it is often the largest component when the camera moves quickly.
const queueSamples = [];
viewer.scene.postRender.addEventListener(() => {
queueSamples.push({
t: performance.now(),
inFlight: Cesium.RequestScheduler.statistics.numberOfActiveRequests,
pending: Cesium.RequestScheduler.statistics.numberOfPendingRequests,
tilesLoading: viewer.scene.primitives.get(0).tilesLoaded ? 0 : 1,
});
if (queueSamples.length > 3600) queueSamples.shift(); // keep one minute at 60 fps
});
A pending count that sits at hundreds while inFlight sits at six is the browser’s per-host connection limit doing the scheduling for you, which is the case backpressure exists to prevent.
4. Measure the frame itself, cheaply
performance.measure around the render is enough, and unlike the DevTools profiler it does not change what it measures.
let frames = 0;
const frameTimes = [];
viewer.scene.preUpdate.addEventListener(() => performance.mark('f0'));
viewer.scene.postRender.addEventListener(() => {
performance.mark('f1');
performance.measure('frame', 'f0', 'f1');
const m = performance.getEntriesByName('frame').pop();
frameTimes.push(m.duration);
performance.clearMarks(); performance.clearMeasures();
if (++frames % 600 === 0) {
const sorted = [...frameTimes].sort((a, b) => a - b);
console.log(
`frames ${frames} | p50 ${sorted[sorted.length >> 1].toFixed(1)} ms` +
` | p95 ${sorted[Math.floor(sorted.length * 0.95)].toFixed(1)} ms` +
` | over 16.7ms: ${(100 * frameTimes.filter((d) => d > 16.7).length / frameTimes.length).toFixed(1)}%`);
frameTimes.length = 0;
}
});
Report the p95 and the fraction over budget rather than the mean. A mean of 12 ms with 8% of frames over 30 ms feels considerably worse than a steady 16 ms, and only the distribution shows it.
5. Fly a scripted path so runs are comparable
A hand-flown camera makes every measurement incomparable with the last one.
async function flyPath(viewer, waypoints, secondsEach = 4) {
for (const wp of waypoints) {
await new Promise((resolve) => {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(wp.lon, wp.lat, wp.height),
orientation: { heading: Cesium.Math.toRadians(wp.heading), pitch: Cesium.Math.toRadians(-35) },
duration: secondsEach,
complete: resolve,
});
});
}
}
await flyPath(viewer, [
{ lon: 10.7522, lat: 59.9139, height: 1800, heading: 0 },
{ lon: 10.7601, lat: 59.9210, height: 600, heading: 45 },
{ lon: 10.7480, lat: 59.9165, height: 250, heading: 190 },
]);
summariseNetwork();
Expected Output & Verification
A representative run over a scripted three-waypoint path on a city tileset:
┌──────────────┬────────┐
│ requests │ 412 │
│ cacheHits │ 1104 │
│ p50_ms │ 38.2 │
│ p95_ms │ 214.7 │
│ medianKB │ 86.4 │
└──────────────┴────────┘
frames 600 | p50 11.4 ms | p95 28.9 ms | over 16.7ms: 12.3%
decode p50 17.9 ms | upload p50 5.2 ms
pending p95 186 | inFlight p95 6
Two findings fall out of that immediately. inFlight pegged at six with a p95 pending of 186 is the browser’s connection limit scheduling the work, so the client needs backpressure before anything else is worth tuning. And a decode p50 of 17.9 ms against an upload of 5.2 ms says the Draco settings, not the geometry size, are what cost the frame.
Verify by re-flying the same path twice and comparing. Anything that moves by more than about ten per cent between identical flights is measuring the network’s mood rather than the client.
Common Errors
Every Resource Timing entry has zeros except duration. The tile CDN is cross-origin and does not send Timing-Allow-Origin. Add it, or the network split is unavailable and only the total remains.
Decode times look impossibly small. The tiles came from the HTTP cache, so there was nothing to fetch and little to parse. Filter on transferSize === 0 and report cached and live separately.
The DevTools profiler shows a completely different frame time. It is instrumenting every call and inflating the budget. Use it to find a hot function once, and take the numbers you quote from performance.measure.
tileVisible never fires for some tiles. Those tiles were culled after loading, which is normal near the frustum edge and pathological if it is most of them — that is the over-fetch signature.
Frequently Asked Questions
Can this instrumentation stay in production?
Yes. Four event listeners and a performance.measure per frame cost well under a tenth of a millisecond, and the data is exactly what you need when a user reports a problem you cannot reproduce.
How do I get these numbers off a user’s machine?
Batch the summary — percentiles, not per-tile records — and post it to your own endpoint every few minutes. Per-tile detail is large, largely uninteresting, and carries the user’s navigation path with it.
Does the camera path need to be identical between runs?
Yes, if the runs are to be compared. Scripted flyTo waypoints with fixed durations are reproducible; a hand-flown path is not, and the difference between two hand-flown runs routinely exceeds the effect being measured.
One practical caveat about where these numbers come from. Everything above measures the client you are sitting at, and the interesting cases are almost never that client — they are a viewer on a throttled connection, on a device three years old, at a time of day when your CDN’s nearest edge is saturated. That is why the instrumentation is designed to be cheap enough to leave running: the summary it emits every few minutes is the only evidence you will have about a problem you cannot reproduce.
When you do collect from real viewers, send percentiles rather than per-tile records. A per-tile stream is large, dominated by uninteresting successes, and carries the viewer’s navigation path with it — which is both a privacy consideration and a needless volume of data. Five numbers per session, batched, answer every question the detail would have.
Finally, keep the scripted flight path in version control next to the client. A performance comparison between two releases is only meaningful if both flew the same route, and a route that lives in somebody’s browser history is not reproducible by anyone else.
Do these listeners affect the frame budget they measure?
Marginally. Four event listeners and one performance.measure per frame cost well under a tenth of a millisecond, which is inside the noise of the numbers being reported and orders of magnitude below what the DevTools profiler adds.
What is a reasonable target for the fraction of frames over budget?
Under about two per cent on the device class you are targeting. Above five per cent the stutter is perceptible to most viewers even when the median frame time looks healthy, and above ten it reads as a broken client regardless of what the average says.
Should the summary include tiles that failed?
Yes, separately. A tileFailed rate that is non-zero but small usually means a handful of availability bits disagree with what was published, and it is invisible in every timing statistic because a failed tile has no load time at all.
Related Guides
- Performance Profiling and Benchmarking — where client profiling fits
- Streaming & Runtime Diagnostics — turning these numbers into a diagnosis
- Benchmarking Draco Decode on Mobile GPUs — when decode is the dominant bar