Hardware Acceleration Limits

Where GPU-Accelerated Rendering Breaks Down

Hardware acceleration is not free. Every composited layer requires a GPU texture allocation. Every frame requires the compositor thread to reconcile the layer tree and submit a frame buffer. When resource consumption exceeds the GPU’s capacity, the browser falls back to software rasterization, introduces main-thread contention, and breaks the 16.6ms frame budget. This topic sits under Compositing and GPU Acceleration and defines the ceiling that every promotion and animation strategy in that area has to stay below.

GPU texture memory budget filling up and forcing tile eviction A fixed VRAM budget bar fills with promoted layer textures; once it overflows the least-recently-used tile is evicted to system RAM and must be re-rasterized. GPU texture budget (e.g. 256MB) layer A layer B large layer C overflow tile evict LRU tile copy to system RAM re-rasterize on next use synchronous, blocks DrawFrame Each promoted layer holds a texture until demoted (will-change: auto). Past the budget, the compositor pays an eviction + re-raster tax every frame.

The three primary failure modes are:

  • Texture memory exhaustion. Each promoted layer’s content is stored as a GPU texture. Mobile GPUs typically cap per-process texture memory at 256–512MB. Exceeding this forces tile eviction: the browser copies the least-recently-used textures to system RAM and must re-rasterize them on demand — a synchronous, main-thread-blocking operation.
  • Compositor thread saturation. The cc compositor thread in Blink has a fixed amount of time per frame to reconcile the layer tree and submit DrawFrame. Too many layers with frequent updates (unbounded will-change declarations, scroll-linked animations with large promoted regions) saturates that budget and delays frame submission. Scroll handlers that read geometry mid-frame compound this; see Scroll and Input Performance for keeping that work off the critical path.
  • Rasterizer queue backpressure. Raster worker threads convert paint records into GPU textures asynchronously. When they fall behind — because textures are large, numerous, or frequently invalidated — the compositor must wait, blocking DrawFrame.

For strategies that stay within these limits, see Compositing and GPU Acceleration, Layer Promotion and Composition, and Transform and Opacity Best Practices.

Trace Analysis

Low-level compositor tracing requires either the DevTools Performance panel or chrome://tracing. In the Performance panel, filter the Compositor thread for UpdateLayers and DrawFrame. Filter the GPU process for memory allocation events.

[Main Thread]   rAF Callback: 4.2ms
[Compositor]    UpdateLayers: 11.8ms  — above the 8ms safe threshold
[Compositor]    DrawFrame: 14.1ms     — frame budget exceeded (+2.5ms combined)
[GPU Process]   GpuMemoryBuffer::Allocate: 42MB (texture: 4096×4096 RGBA)
[Main Thread]   Forced Layout: 1.8ms  (triggered by read-after-write during rAF)

The UpdateLayers spike indicates the layer tree changed significantly mid-frame — likely a new element was promoted or an existing layer’s bounds changed. The 42MB texture allocation for a 4096×4096 RGBA buffer is a red flag: a single texture of that size consumes 64MB uncompressed on the GPU.

Laid out on a single frame’s timeline, the overrun is unmistakable: the main-thread rAF callback runs first, then the compositor commits, and DrawFrame spills past the 16.6ms deadline while the GPU process is still servicing a texture allocation.

Annotated compositor trace against the 16.6ms frame budget Three thread lanes show the rAF callback, UpdateLayers and DrawFrame work overrunning the frame budget line while the GPU process allocates a texture. One frame: work overruns the deadline 16.6ms budget Main Thread rAF 4.2ms Compositor UpdateLayers 11.8ms DrawFrame 14.1ms GPU Process Allocate 42MB texture DrawFrame ends at 18.3ms — +1.7ms over budget, so the frame is dropped. Compositor commit is gated on the main thread finishing its rAF work first.

In chrome://tracing, enable the cc, viz, and gpu categories to capture cc::LayerTreeHostImpl::UpdateLayers, viz::GpuFrameSink::SubmitCompositorFrame, and GpuMemoryBuffer allocation/eviction events.

Mitigation

Every mitigation below is one branch of the same decision: promote a layer only when motion demands it, the element is visible, and its texture stays small. Walk the tree before adding any will-change declaration.

Decision tree for staying under the GPU texture budget Three sequential checks — is it animating, is it in the viewport, is its paint bounds small — route each element to promotion or a cheaper fallback. Promotion decision: stay under the texture budget Animating transform/opacity? Keep on main thread — no texture In viewport + bleed margin? Demote: will-change: auto Paint bounds ≤ viewport? contain: strict; split into tiles Promote: will-change: transform No Yes No Yes No Yes

Limit active layer count and texture dimensions

const FRAME_BUDGET_MS = 16.6
const COMPOSITOR_SAFE_THRESHOLD_MS = 8.0

class VirtualizedRenderer {
  constructor() {
    this.activeLayers = new Set()
  }

  scheduleFrame(updateFn) {
    requestAnimationFrame((timestamp) => {
      updateFn()
      this.purgeOffscreenLayers()
    })
  }

  purgeOffscreenLayers() {
    // Demote layers for elements that have scrolled out of the viewport
    // Frees GPU texture memory before the next frame is submitted
    this.activeLayers.forEach((el) => {
      if (!this.isInViewport(el)) {
        el.style.willChange = 'auto'
        this.activeLayers.delete(el)
      }
    })
  }

  isInViewport(el) {
    const rect = el.getBoundingClientRect()
    return rect.top < window.innerHeight && rect.bottom > 0
  }
}

Explicitly setting will-change: auto demotes the element and releases its GPU texture. On mobile GPUs, proactive demotion of off-screen elements is the most reliable way to stay within memory limits during long scroll sessions.

Avoid oversized textures

A layer promoted with will-change: transform allocates a texture matching the element’s paint bounds. An element covering the full viewport at 3x device pixel ratio requires a 3240×2160 RGBA texture — ~27MB. Use contain: strict to limit the paint bounds, and consider splitting large regions into smaller independent tiles rather than promoting the whole container. When the promotion exists only to drive motion, keep the moving subtree small using the patterns in Animation Performance Patterns.

Replace runtime CSS filters with pre-rendered assets

filter: blur() and filter: drop-shadow() force the browser to allocate intermediate offscreen buffers for each composited layer the filter applies to. These buffers compound VRAM consumption significantly. Replacing them with pre-rendered WebP or AVIF assets eliminates the intermediate buffer allocation entirely.

Viewport-scoped layer promotion

Restrict active GPU textures to the visible viewport plus a modest bleed margin:

const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach(({ target, isIntersecting }) => {
      target.style.willChange = isIntersecting ? 'transform' : 'auto'
    })
  },
  { rootMargin: '200px' }, // 200px bleed margin for smooth scroll
)
document.querySelectorAll('.animated-card').forEach((el) => observer.observe(el))

Validation

Metric Target Where to measure
DrawFrame duration (95th pctl) < 14ms chrome://tracingcc::Scheduler::DrawFrame
Active compositor layers < 100 per viewport on mobile DevTools Layers panel
Texture memory footprint < 256MB (mid-tier GPU) chrome://gpu → Video Memory
Forced reflows per rAF 0 Main thread → Layout → Forced Reflow markers
Frame drop rate (10s scroll) < 2% PerformanceObserver on longtask + rAF delta timing

A passing profile keeps every measured value to the left of its limit marker. The bars below show a healthy mid-tier mobile run where each metric sits under budget with headroom.

Validation dashboard of key metrics against their limits Four horizontal bars show DrawFrame, active layers, texture memory and frame drop rate all sitting left of their limit markers. Healthy run: every metric under its limit limit DrawFrame p95 13ms / limit 14ms PASS Active layers 80 / limit 100 PASS Texture memory 210MB / limit 256MB PASS Frame drop (10s) 1.2% / limit 2% PASS

For the specific Chrome internals around tile cache limits and eviction behavior, see GPU memory limits in Chrome compositing. For turning these targets into recorded, alertable numbers, see Rendering Performance Metrics and Tooling.

The GPU Memory Ceiling

Hardware acceleration is bounded by a resource that is easy to forget because it is invisible in most tooling: GPU texture memory. Every compositor layer is a texture whose cost is its painted area in device pixels times four bytes, and mobile GPUs commonly cap the budget shared across all tabs at a few hundred megabytes. A single full-viewport element on a 3× phone is over 10MB; promote a dozen and you are past 100MB before any content scrolls. When the budget is exhausted the engine evicts the least-recently-used textures to system RAM and re-rasterizes them on demand, which shows up as frame-pacing stalls precisely when you scroll back to content that was discarded. The acceleration that made an animation smooth becomes the cause of a stutter once the ceiling is hit, which is the central irony of over-promotion, explored concretely in GPU memory limits in Chrome compositing.

The defence is to treat layers as a budget you spend deliberately. Promote the smallest element that actually animates rather than its container, add will-change right before an animation and remove it after so textures do not stay resident, and audit the Layers panel to confirm you created the layers you intended and no more. Overlap-driven promotion is the sneakiest source of waste: promoting one element can force everything painted on top of it onto its own layer to preserve paint order, multiplying memory from a single hint. Watching the composited-layer count and total memory in the Layers panel is the only reliable way to catch this, because nothing about the page’s appearance signals that you are near the ceiling until you cross it.

// Estimate the texture cost of the current promoted set before it bites.
const dpr = window.devicePixelRatio || 1
const promoted = document.querySelectorAll('[style*="will-change"], .promoted')
const mb = [...promoted].reduce((s, el) => {
  const r = el.getBoundingClientRect()
  return s + Math.ceil(r.width * dpr) * Math.ceil(r.height * dpr) * 4
}, 0) / 1e6
console.log(`~${mb.toFixed(1)}MB across ${promoted.length} layers`)

When Acceleration Silently Declines

The second limit is that promotion is a request the engine can refuse. WebKit on iOS is conservative about spawning layers and may ignore a will-change hint that Blink would honour, so an animation that is buttery on desktop Chrome can fall back to main-thread layout and paint on an iPhone. Under memory pressure any engine can decline promotion or evict an existing layer. And certain property combinations defeat the fast path entirely: a compositor animation on transform drops back to the main thread if a non-compositable property animates alongside it, or if the element cannot be isolated onto its own layer. The failure is silent — the page still renders — which is why it must be caught by profiling on the target engine rather than assumed from desktop behaviour.

The practical consequence is that you cannot verify acceleration on your development machine alone. Profile on a real device for the engine your audience actually uses, read chrome://gpu (or the Safari equivalent) to confirm which features are hardware-accelerated on that device, and check a Performance trace for Composite Layers on the compositor track with an idle main thread. When the acceleration is declined, the trace shows per-frame Layout or Paint on the main track instead, which is the unambiguous signal that a hint you relied on was not honoured. Designing animations that degrade gracefully when promotion is refused — rather than assuming it will always be granted — is what keeps a page smooth across the full range of hardware.

Designing for the Limits

Because acceleration is bounded by memory and can be silently declined, the robust approach is to design animations that stay within the budget and degrade gracefully when promotion is refused. Staying within budget means promoting the smallest element that moves rather than its container, toggling will-change around the animation rather than leaving it resident, and keeping the count of simultaneously promoted layers small — a handful of intentional layers, not a swarm from an over-broad selector or an overlap cascade. The Layers panel is the instrument that makes this measurable: it shows every layer, its memory, and the reason it was promoted, so you can see when you are approaching the ceiling before the eviction stalls tell you the hard way.

Degrading gracefully means not assuming the fast path. On engines that decline promotion, or under memory pressure, a transform animation can fall back to the main thread, so it should still be a transform animation — cheap even on the main thread relative to a geometry animation — rather than something that only works if a layer is granted. Testing on a real device for the engine your audience uses is the only way to see the fallback behaviour, because it never appears on a well-resourced desktop. The mindset that keeps a page smooth across the full hardware range is to treat compositor acceleration as a bonus the engine may grant, not a guarantee you can build on, and to make sure the un-accelerated version is still acceptable. This is especially true for animation counts: a design that promotes five elements is fine on nearly any device, while one that promotes fifty may run beautifully on a desktop and evict textures continuously on a budget phone. When a feature needs many simultaneous animations, the sustainable answer is usually to reduce the count — stagger them, animate a shared parent, or drop the effect on smaller viewports — rather than to assume the GPU will absorb it. Measuring the composited-layer memory in the Layers panel on a representative low-end device, not just a development machine, is the check that catches an over-ambitious animation budget before users on constrained hardware feel it as stutter. Making that low-end measurement part of the review for any feature that adds animation is what keeps the acceleration budget honest, because the ceiling is invisible until it is crossed and the crossing only happens on the devices you are least likely to be testing on.

Frequently Asked Questions

Why does exceeding GPU texture memory hurt more than just using more system RAM?

Because eviction is synchronous on the path that matters. When the texture budget overflows, the compositor copies least-recently-used textures to system RAM, and re-rasterizes them the moment they scroll back into view. That re-raster blocks DrawFrame, so instead of a one-time memory cost you pay a recurring per-frame tax that drops frames. RAM pressure alone would be invisible to the frame budget; texture eviction is not.

How many compositor layers is too many?

On mid-tier mobile, aim for fewer than 100 active layers per viewport. There is no hard cap — the real limit is the compositor thread’s time to reconcile the layer tree and the GPU’s texture memory. A hundred small static layers can be cheaper than ten oversized ones that each invalidate every frame. Check the DevTools Layers panel for count and the UpdateLayers trace event for the reconciliation cost.

Does will-change: transform always improve performance?

No. It promotes the element to its own layer, which allocates a GPU texture and adds compositor work whether or not the element is animating. Applied broadly — for example to every card in a list — it exhausts texture memory and saturates the compositor. Promote only elements that are actively animating and in the viewport, and demote with will-change: auto as soon as motion stops.

How do I tell if the browser fell back to software rasterization?

Check chrome://gpu — the “Graphics Feature Status” list shows whether rasterization and compositing are hardware-accelerated or software-only. In a trace, software fallback appears as raster work running on CPU worker threads with no corresponding GPU-process texture uploads, and frame times climb sharply. Exhausting the texture budget or hitting a driver blocklist entry are the common triggers.