GPU Memory & Texture Management

Part of Performance Budgeting & GPU Architecture.

Every compositor layer is backed by a bitmap held in graphics memory — a texture. When you promote an element so that its transform and opacity updates run on the GPU thread, you are asking the browser to rasterise that element once, upload the pixels to the graphics card, and keep them resident so the compositor can re-blend them each frame without touching the main thread. That resident texture is not free: it occupies a slice of VRAM proportional to the element’s painted area at device resolution. Manage that budget well and animations stay smooth on a mid-range phone; manage it badly and the browser evicts textures under pressure, re-uploading them mid-animation and producing exactly the stutter promotion was supposed to prevent.


What a texture actually costs

A compositor layer stores its pixels in an uncompressed RGBA bitmap: four bytes per pixel — one each for red, green, blue and alpha. The pixel count is measured in physical device pixels, not CSS pixels, so the device pixel ratio (DPR) enters the calculation twice: once for width, once for height. The resulting formula is the single most useful number in GPU budgeting:

bytes ≈ width × height × 4 × DPR²

Because DPR is squared, it dominates. A modest 400 × 300 card costs roughly 1.83 MB on a DPR 2 laptop, but 4.12 MB on a DPR 3 phone — the device least able to spare it. A full-viewport hero on a 1920 × 1080 layout at DPR 2 allocates around 31.6 MB in a single texture. Multiply either figure across a grid of simultaneously promoted elements and the total climbs past the VRAM a low-end integrated GPU is willing to hand a single browser tab.

Element pixels to GPU texture memory A 400 by 300 CSS pixel element is multiplied by four bytes per pixel for RGBA and by device pixel ratio squared, yielding roughly 1.83 megabytes at DPR 2 and 4.12 megabytes at DPR 3. A budget bar below shows promoted layers accumulating toward a VRAM ceiling. Texture memory of one compositor layer Element 400 × 300 CSS pixels × 4 bytes/px RGBA channels × DPR² device density ≈ 1.83 MB at DPR 2 ≈ 4.12 MB at DPR 3 resident in VRAM Layer budget: textures accumulate toward the VRAM ceiling hero 31 MB cards 24 MB nav 18 MB safe ceiling Each promoted element adds a resident texture. Beyond the ceiling the compositor evicts and re-uploads, turning a cheap transform animation into a burst of expensive upload stalls. promoted layer texture VRAM headroom limit for the target device

The alpha channel is always allocated even for a fully opaque element, because the compositor blends every layer against what sits behind it. Some browsers can drop to a 16-bit format on constrained hardware, but you should budget for the 4-byte worst case rather than rely on a compression that may not apply.


Execution model: allocate, upload, cache, evict

Texture management spans two threads and several distinct moments in the frame lifecycle. Understanding when each cost is paid tells you why a promotion that looks free in isolation can be ruinous in aggregate.

Pre-paint allocation. When the style system resolves will-change: transform — or any of the other promotion triggers — the layer is scheduled during the pre-paint phase, before the frame is drawn. The browser reserves a texture of the element’s size at device resolution. Nothing is painted yet; this is bookkeeping in the layer tree.

Rasterisation and upload. The element’s pixels are rasterised on the CPU (or a raster worker) into the reserved bitmap, then uploaded across the graphics bus into GPU memory. Upload is bandwidth-bound and is the hidden cost of promotion: a single 31 MB hero texture takes real milliseconds to transfer, and that transfer is synchronised with compositing. Promote a dozen large elements on one frame and the upload burst alone can miss the deadline — see budgeting concurrent animations per frame for how these one-frame spikes accumulate.

Compositing from the tile cache. Once resident, the texture lives in the compositor’s tile cache. Each animation frame the GPU reads the cached texture and applies the current transform matrix and opacity — no re-rasterisation, no upload, no main-thread involvement. This is the steady-state win: the frame is nearly free because the expensive work already happened.

Tiling. Large layers are not stored as one monolithic texture. The compositor splits them into fixed-size tiles (commonly 256×256 device pixels) so it can rasterise and upload only the regions that are visible or changing. Tiling is why a tall scrolled container does not allocate its full document height up front, and why partial invalidations re-upload just a few tiles rather than the whole surface.

Eviction. GPU memory is finite and shared with every other tab and the operating system. When resident textures exceed what the browser is allotted, the compositor evicts the least-recently-used tiles. If an evicted layer is still animating, its tiles must be re-rasterised and re-uploaded on demand — the eviction/re-upload thrash that turns a promotion strategy into a liability. Releasing textures promptly, covered under layer promotion & will-change strategy, is what keeps you clear of the eviction threshold.


Property and API reference

Each of these mechanisms changes the layer tree and therefore the texture budget. Read the memory column before reaching for any of them.

Property / value Compositing tier Texture memory implication Notes
will-change: transform Composite Allocates one texture at element size × DPR² for the promotion window Prefer dynamic apply/remove; static declarations hold VRAM permanently
will-change: opacity Composite Same footprint as transform; the whole element is textured to fade it Batch with transform on one element rather than promoting twice
transform: translateZ(0) Composite Allocates a texture that persists until the style is removed Legacy hack; no lifecycle, so it is the worst offender for VRAM leaks
transform: translate3d(0,0,0) Composite Identical footprint and caveats to translateZ(0) Same permanent allocation; use managed will-change instead
contain: paint Paint (scoped) No promotion by itself; bounds paint so tiles invalidate less area Shrinks re-upload cost by isolating the repaint region
contain: layout paint Paint (scoped) Establishes a stacking context; can enable smaller, independent layers Reduces cascade of invalidations into neighbouring tiles
content-visibility: auto Style (skips render) Off-screen subtrees allocate no texture until near the viewport Highest-leverage tool for long lists; frees textures as content scrolls away
opacity < 1 (animated) Composite (if promoted) Textures the element so alpha can be applied per frame Composited only when the element is on its own layer

Annotated patterns

1. Size-aware promotion: refuse to promote oversized elements

Intent: gate will-change on the element’s texture cost so a large surface never silently claims tens of megabytes of VRAM.

// Promote only if the resulting texture stays under a per-layer cap.
const DPR = window.devicePixelRatio || 1;
const MAX_TEXTURE_BYTES = 8 * 1024 * 1024; // 8 MB per-layer ceiling

const prefersReducedMotion =
  window.matchMedia('(prefers-reduced-motion: reduce)').matches;

function promoteIfAffordable(el) {
  if (prefersReducedMotion) return; // no texture for reduced-motion users
  const { width, height } = el.getBoundingClientRect();
  const bytes = width * height * 4 * DPR * DPR; // w × h × 4 × DPR²
  if (bytes <= MAX_TEXTURE_BYTES) {
    el.style.willChange = 'transform, opacity';
  }
  // Oversized elements fall back to a main-thread animation or none at all.
}
@media (prefers-reduced-motion: reduce) {
  .promotable {
    will-change: auto;
    transition: none;
    transform: none;
  }
}

Rendering Impact: composite while animating; the getBoundingClientRect read is a one-off main-thread cost paid before the loop, never inside it.

2. Release the texture the instant motion ends

Intent: reset will-change on transitionend so the compositor can evict the tile cache and reclaim VRAM immediately.

/* Base — no texture allocated; element paints in normal flow. */
.panel {
  transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1),
              opacity 0.3s ease;
}

/* Active — promotion happens as the state changes, not before. */
.panel.is-open {
  will-change: transform, opacity;
  transform: translateY(0);
  opacity: 1;
}

/* Accessibility gate — no motion, no promotion, no texture. */
@media (prefers-reduced-motion: reduce) {
  .panel,
  .panel.is-open {
    will-change: auto;
    transition: none;
    transform: none;
  }
}
// Demote on completion so the tile cache can drop the texture.
panel.addEventListener('transitionend', () => {
  panel.style.willChange = 'auto';
}, { once: true });

Rendering Impact: composite during the transition, then the texture is released back to VRAM; only the transitionend handler touches the main-thread.

3. Keep off-screen content out of GPU memory entirely

Intent: use content-visibility so a long list never textures rows the user cannot see, capping the resident footprint regardless of list length.

/* Each row skips render — and layerisation — until it nears the viewport. */
.feed-item {
  content-visibility: auto;
  contain-intrinsic-size: auto 180px; /* reserve height to avoid scroll jump */
}

/* Motion only for on-screen rows, only for users who allow it. */
@media (prefers-reduced-motion: no-preference) {
  .feed-item.is-entering {
    animation: rise 0.35s ease-out both;
  }
  @keyframes rise {
    from { opacity: 0; transform: translateY(16px); }
    to   { opacity: 1; transform: translateY(0); }
  }
}

@media (prefers-reduced-motion: reduce) {
  .feed-item.is-entering {
    animation: none;
    opacity: 1;
    transform: none;
  }
}

Rendering Impact: off-screen rows trigger neither paint nor composite; on-screen entry animation is composite only. Resident texture memory stays flat as the list grows.


DevTools workflow: reading the texture budget

The Layers panel exposes a per-layer memory estimate, and chrome://gpu reports the process-wide budget the browser has negotiated with the driver.

  1. Open DevTools, then the three-dot menu → More toolsLayers.
  2. Select each layer to read its Memory estimate — this figure is the width × height × 4 × DPR² cost the browser actually reserved. Sum the estimates across every layer present at once.
  3. Watch the Compositing reasons for each layer to confirm the promotion was intentional and not an overlap side effect.
  4. Open a new tab at chrome://gpu and search for the GPU memory buffer / texture budget entries to see the ceiling the driver granted the process.
  5. In the Performance panel, enable the Memory checkbox before recording. The GPU memory track shows the resident total climbing and — crucially — whether it plateaus or keeps growing, which signals textures that are never released.

For the full step-by-step diagnosis, including spotting a runaway layer count from a single stray rule, see diagnosing GPU texture memory in DevTools.


Failure modes and fixes

Problem: A responsive image grid animates smoothly on desktop but stutters badly on a mid-range Android phone. Root cause: The phone’s DPR 3 makes every texture 2.25× larger than the DPR 2 desktop measurement. The combined footprint crosses the device’s VRAM headroom and the compositor begins evicting and re-uploading tiles. Fix: Recompute the budget with DPR² for the real device, cap the number of concurrently promoted cells, and lean on content-visibility: auto so off-screen cells hold no texture.


Problem: GPU memory in the Performance track climbs steadily throughout a session and never falls. Root cause: will-change is set statically in CSS on a class that matches many elements, so every match holds a resident texture for the life of the page, animated or not. Fix: Remove the static declaration. Apply will-change from JavaScript only for the active animation and reset it to auto on completion so the tile cache can evict.


Problem: Promoting a full-screen background element to “help” its subtle fade makes the first frame of the fade hitch. Root cause: The 31 MB full-viewport texture must be rasterised and uploaded across the bus before the first composited frame; that upload spike overruns the frame budget. Fix: Do not promote very large, rarely-animated surfaces. Fade a smaller overlay instead, or accept a one-time main-thread paint rather than paying a large upload every time the layer is created.


Problem: Text inside a promoted element looks soft on a high-density screen. Root cause: The layer was rasterised at logical resolution and upscaled during compositing rather than being backed at the full device pixel ratio. Fix: Pair the promotion with an explicit transform: translateZ(0) to force rasterisation at device resolution, or animate transform rather than opacity so the element is not textured for the fade at all.


Accessibility and reduced-motion

Texture allocation is a direct consequence of animation, so the accessibility rule is simple: if the motion should not play, the texture should not exist. When a user sets prefers-reduced-motion: reduce, skip promotion entirely — do not allocate a layer for an animation you are about to suppress. This is both a correctness measure and a memory optimisation, since reduced-motion users on low-end hardware benefit most from the freed VRAM.

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    will-change: auto !important; /* release every texture reserved for motion */
  }
}

Rendering Impact: style only — no layer is promoted, so no texture is uploaded and no composite work is scheduled for motion.

The dedicated home for these decisions is the accessible motion architecture section; its guidance on prefers-reduced-motion architecture pairs naturally with the memory discipline here — suppressing motion is also the cheapest way to stay inside a VRAM budget.


Frequently asked questions

How do I calculate the GPU memory a single compositor layer uses?

Multiply the element’s CSS pixel area by four bytes per pixel for an RGBA texture, then by the square of the device pixel ratio. A 400 × 300 element on a DPR 2 display occupies 400 × 300 × 4 × 4 ≈ 1.83 MB; the same element on a DPR 3 phone occupies × 9 ≈ 4.12 MB. Device pixel ratio dominates the cost because it is squared.

Why does device pixel ratio affect texture memory so much?

A texture is stored at physical device resolution, not CSS resolution. Raising the device pixel ratio from 2 to 3 increases both the width and the height of the backing store by 1.5×, so the pixel count — and therefore the memory — grows by 1.5² = 2.25×. The DPR term is squared in the formula, which is why high-density phones exhaust VRAM fastest.

Can promoting an element to a layer ever make performance worse?

Yes. Promotion trades main-thread paint work for GPU memory and upload bandwidth. If the element is large, rarely animated, or one of many promoted at once, the texture allocation and upload cost outweigh the compositing benefit. On memory-constrained devices excess layers force texture eviction, which triggers re-upload stalls that are more expensive than the paint you avoided.

What is texture upload cost and when does it hurt?

When a layer is first promoted or its contents change, the browser rasterises it on the CPU and uploads the resulting bitmap to GPU memory across the graphics bus. Upload is bandwidth-bound and happens on or synchronised with the compositor; a burst of newly promoted large layers on a single frame can blow the frame budget even though the subsequent transform animation is cheap.

Does content-visibility reduce GPU texture memory?

Indirectly, yes. content-visibility: auto skips rendering — including layerisation and rasterisation — for off-screen subtrees, so their content never allocates a texture until it approaches the viewport. It is the most effective way to keep long lists from holding textures for content the user cannot see, complementing dynamic will-change management.