Diagnosing GPU Texture Memory in DevTools
Part of GPU Memory & Texture Management in Performance Budgeting & GPU Architecture.
The problem: memory you cannot see in the JS heap
A page can pass every JavaScript memory check and still stutter because the pressure lives in graphics memory, not the JS heap. Promoted compositor layers hold resident textures that the standard Memory profiler never shows you. When their combined footprint crosses the budget the driver granted the tab, the browser evicts and re-uploads tiles mid-animation, and a transform that should cost nothing suddenly hitches. Diagnosis means measuring three separate figures — the process budget, the per-layer footprint, and the resident total over time — and then tracing an unexpected total back to the rule that caused it.
Root cause: how texture memory grows out of sight
Each promoted element is backed by a bitmap sized width × height × 4 × DPR², held in VRAM for as long as the layer exists — the accounting is derived in full under GPU memory & texture management. Three patterns push the resident total past the budget:
- Static promotion at scale. A
will-changeortranslateZ(0)declaration in a rule that matches many elements promotes all of them at once, most never animating. - Missing release. Layers promoted for a one-shot animation are never demoted, so their textures stay resident after the motion ends.
- Density blindness. A footprint measured on a DPR 2 desktop is 2.25× larger on a DPR 3 phone, silently crossing a ceiling that looked comfortable.
None of these surface in the JS heap snapshot. You need the GPU-specific tooling below.
Step-by-step diagnosis
Work through these in order. Each stage narrows the search from “how much” to “which layer” to “which rule”.
1. Read the process budget in chrome://gpu.
Open a tab at chrome://gpu. Search the page for the GPU memory buffer and texture budget entries. This is the ceiling the driver handed the browser process — the number your combined layer footprint must stay under. Note it before you measure anything else, because “31 MB of textures” means nothing until you know whether the budget is 256 MB or 64 MB.
2. Sum the per-layer estimates in the Layers panel. Open DevTools → three-dot menu → More tools → Layers. Interact with the page so animated elements are in their active state. Select each layer in the left tree and read its Memory estimate in the details pane. Add the estimates together: this resident total is what you compare against the budget from step 1.
3. Watch the resident total over time in the Performance panel. In the Performance panel, tick the Memory checkbox before recording. Record a representative session — scroll, open menus, run the animations. Inspect the GPU memory track in the result. A healthy page shows the line rising as content promotes and falling as it demotes. A leak shows a staircase that only ever climbs; that shape is your signal that textures are allocated but never released.
4. Spot the layer explosion. Back in the Layers panel, scan the layer count. A handful is expected; dozens or hundreds is an explosion. Select several of the surplus layers and read their Compositing reasons — if they all report the same reason, such as “has a will-change property”, a single static rule is promoting a whole set of elements. Sort your mental model by memory: the biggest contributors are either one huge surface or many identical small ones.
5. Trace the reason back to a rule.
Select an offending layer and note the element. Inspect it in the Elements panel and look at its Computed styles for will-change, transform, content-visibility, or an ancestor that forced promotion via overlap. The declaration you find — usually a base-class rule rather than a state rule — is the root cause.
6. Confirm the fix. After removing the static promotion (or moving it into a JavaScript-managed lifecycle), re-record the Performance memory track. The resident total should drop and then plateau, and the Layers panel count should fall back to the handful you expect.
Decision matrix: symptom to root cause
Stop at the first row whose symptom matches.
| Symptom in DevTools | Likely root cause | Fix |
|---|---|---|
| GPU memory track climbs and never falls | will-change never reset after animation |
Demote to will-change: auto on transitionend / animationend |
| Layer count in the dozens, shared compositing reason | Static promotion rule matching many elements | Remove from base CSS; promote per-element from JS only while animating |
| One layer with a very large memory estimate | Full-viewport surface promoted needlessly | Do not promote large, rarely-animated elements; animate a smaller overlay |
| Fine on desktop, evicts on mobile | DPR 3 makes each texture 2.25× larger | Recompute the budget with real DPR²; cap concurrent layers |
| Off-screen list rows each hold a texture | Long list promotes every row up front | Apply content-visibility: auto so off-screen rows allocate no texture |
Instrumenting the budget in code
DevTools tells you the state now; this snippet logs the estimated footprint continuously so you can catch regressions in a real session.
// Intent: log the estimated resident texture footprint of promoted layers.
// Runs off the animation path — never inside a rAF or transition callback.
const DPR = window.devicePixelRatio || 1;
function estimateLayerBytes(el) {
const { width, height } = el.getBoundingClientRect();
return width * height * 4 * DPR * DPR; // w × h × 4 × DPR²
}
function auditPromotedLayers(selector = '[style*="will-change"]') {
const promoted = document.querySelectorAll(selector);
let total = 0;
promoted.forEach(el => { total += estimateLayerBytes(el); });
console.info(
`Promoted layers: ${promoted.length}, ` +
`estimated VRAM: ${(total / 1024 / 1024).toFixed(1)} MB`
);
return total;
}
// Sample once a second in development; strip from production builds.
if (window.location.hostname === 'localhost') {
setInterval(auditPromotedLayers, 1000);
}
Rendering Impact:
main-threadmeasurement only — thegetBoundingClientRectreads run outside any animation frame, so they never force a synchronous layout mid-motion. Nocompositework is added.
Because this is a diagnostic that reads geometry, keep it out of the animation loop. If you must correlate it with motion, disable it whenever the user prefers reduced motion so you are not measuring layers that should not exist:
const prefersReducedMotion =
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReducedMotion && window.location.hostname === 'localhost') {
setInterval(auditPromotedLayers, 1000);
}
Rendering Impact:
main-threadonly; no layers are promoted by the audit itself, so it adds no texture memory orcompositecost.
Verification checklist
Constraints and trade-offs
- The Layers panel Memory estimate covers the reserved backing store but not tiling scratch buffers or the compositor’s internal allocations, so the
chrome://gpuprocess figure will always read somewhat higher than your summed estimates. chrome://gpureports a browser-wide budget shared across tabs; a figure that looks safe in isolation can be squeezed when the user has many tabs open.- The Layers panel adds observation overhead and can itself nudge the browser into keeping layers alive slightly longer; take memory measurements with the panel closed once you have identified the culprits.
- Firefox exposes layer information differently (its Layers view is Nightly-only); the numeric budgeting here assumes Chromium’s tooling, though the underlying texture maths is engine-independent.
- Emulated DPR in DevTools changes layout but does not always reproduce a real device’s exact texture format, so confirm the worst case on physical low-end hardware.
Related
- GPU Memory & Texture Management — the parent guide deriving the texture-cost formula and the tile-cache lifecycle
- Auditing Compositor Layers in the DevTools Layers Panel — reading promotion reasons and counting layers to find unintended promotions
- Layer Promotion & will-change Strategy — the lifecycle discipline that keeps the resident total falling as well as rising