Zygote
Graphicsnerd

HWUI and the RenderThread

How Android turns a view tree into GPU work: display lists, RenderNodes, the RenderThread that replays them, Skia's backends, and what 'hardware accelerated' actually promises.

13 minAPI 11–37 · 17interactivequizverified 2026-10-27

"Hardware accelerated" is one of the most repeated and least explained phrases in Android. It does not mean your drawing code runs on the GPU, and it does not mean everything is fast. It means the framework records your drawing as data, hands that data to a separate thread, and lets that thread decide how to turn it into GPU commands.

TL;DR#

  • HWUI is Android's hardware-accelerated 2D renderer. It records drawing into RenderNodes and replays them.
  • The UI thread records; the RenderThread replays. Recording is cheap and happens on the thread that also handles input; replay is expensive and happens in parallel with the next frame.
  • is the 2D library underneath. Its backend has moved from OpenGL ES toward Vulkan on devices that support it.
  • Hardware acceleration arrived for Views in Android 3.0 and became the default later; the RenderThread arrived in Android 5.0, API 21.
  • The cost model is: cheap to re-record a changed view, expensive to change something that invalidates a large subtree.

Mental model#

Think of a theatre with a prompt script and a lighting operator.

The prompt script is the display list. The director — your UI thread — writes down what should happen: "curtain up, spotlight stage left, actor enters". Writing it is fast.

The lighting operator is the RenderThread. They read the script and actually move the lights. Moving lights is slow, but it happens in parallel with the director writing the next scene.

The stage is the GPU. It executes the operator's cues.

Where the analogy breaks#

A play has one script and one performance. Android has a tree of scripts — one display list per view — and the operator can reuse an old script for a view that did not change. Most of the skill in writing fast Android UI is about keeping as much of the script unchanged as possible, so the operator only has to redo a few cues.

The big picture#

Loading diagram…
Text description of this diagram

The frame pipeline with the boundary between recording and replay marked. On the UI thread, measure and layout produce geometry and draw records display lists into RenderNodes. The SyncAndDrawFrame call crosses to the RenderThread, which replays those lists through HWUI and Skia into GPU commands, renders into a BufferQueue slot and queues it. The point is that the expensive half runs on a thread that is not the one handling your input.

Look at where the thread boundary sits: after recording, before replay. Everything to the left of it must finish before the frame can be handed off; everything to the right of it can overlap with the next frame's left side.

Deep dive#

Display lists are the intermediate representation#

When a View draws, it does not call glDrawArrays. It calls methods like canvas.drawRect, canvas.drawText and canvas.drawBitmap, and those calls are recorded into a DisplayList — a compact, replayable description of drawing operations with their geometry, paint and clip.

The recorded tree is made of RenderNodes.1 Each View that draws has one. A RenderNode can be reused across frames if nothing about it changed, which is why the framework tracks dirty flags so carefully. A view that only moved does not need to re-record its drawing; only its transform changes.

The RenderThread replays#

ThreadedRenderer (the class that replaced the old hardware renderer) owns a RenderProxy, which talks to the RenderThread in the app's process. On each frame the UI thread calls syncAndDrawFrame, which blocks only long enough to copy the recorded tree across; the actual replay happens on the RenderThread.2

The RenderThread's job:

  1. Walk the RenderNode tree and apply transforms, clips and alpha.
  2. Convert each drawing operation into Skia calls.
  3. Let Skia translate those into the active GPU backend.
  4. Render into the surface's current buffer.
  5. Signal the GPU fence and queue the buffer.

Because this runs on its own thread, a heavy GPU frame does not block the UI thread from handling the next touch. It does still block the next frame, which is why GPU-bound apps jank differently from CPU-bound ones: input stays responsive, but the display stalls.

Skia and its backends#

is the 2D graphics library that draws Android's shapes, text and images. It has multiple backends: a CPU rasteriser and GPU backends. For most of Android's life the GPU backend was OpenGL ES; newer releases add a Vulkan backend, often called skiavk in AOSP discussions.3

The backend choice is a platform and driver decision, not an app decision. An app records the same display list either way; the difference is which GPU API eventually executes it.

What "hardware accelerated" actually changes#

There are two rendering paths for a View, and the difference is not just speed:

SoftwareHardware accelerated
Where drawing happensUI thread, into a bitmapRenderThread, into a GPU buffer
Cost modelproportional to pixels touchedproportional to pixels touched, but parallel and cached
Unsupported operationsall Canvas operationssome operations are not supported and are ignored or emulated
Text and shapesCPU rasterGPU, with different anti-aliasing behaviour

The unsupported-operations column is the one that bites. A few Canvas APIs have no hardware path; historically Canvas.clipPath with certain arguments and some Paint effects fell back or were ignored. That is why a view can look correct in the layout editor and wrong on a device, or vice versa.

The cost model, concretely#

Roughly, the cost of a frame is:

cost = record(changed views) + replay(all visible RenderNodes) + GPU fill(overdraw)

Three things make that number large:

  • Re-recording too much. Invalidating a parent invalidates its subtree. Calling requestLayout() on a root re-measures everything below it.
  • Too many RenderNodes. Each node has overhead. Deep hierarchies with many tiny views cost more than a few large ones, which is part of why Compose's flatter model can win.
  • Overdraw. Drawing the same pixel repeatedly. A stack of opaque backgrounds is pure waste; the GPU still fills each layer unless the framework can prove it is hidden.

Why the split changed what "smooth" means#

Before the RenderThread, smoothness was a single number: how long the whole frame took. After it, there are two numbers, and the relationship between them determines the kind of problem you have.

If the UI thread is the bottleneck, the app feels unresponsive: touches queue up, the frame rate drops, and the device may raise an input ANR if the stall is long enough.

If the RenderThread or GPU is the bottleneck, the app feels sluggish but responsive: touches are handled immediately, animations continue at a lower frame rate, and the trace shows a short UI-thread slice next to a long render slice.

That distinction is the first question in any rendering investigation, and it is the reason a trace is more useful than a CPU profile.

RenderNodes and invalidation#

Every view that draws owns a RenderNode. When the view changes, it calls invalidate(), which marks the node dirty and schedules a traversal. On the next frame the framework re-records the display list for that node — and, if the node's geometry changed, re-records the nodes above it that depend on that geometry.

The cost model follows directly:

  • Changing a leaf's content invalidates one node: cheap.
  • Changing a leaf's size invalidates the layout of its parent: more expensive.
  • Removing a view from the middle of a hierarchy invalidates the siblings' positions: expensive.

This is why RecyclerView is so carefully engineered around not re-laying-out the world on every scroll, and why the same patterns (stable ids, fixed-size items, setHasFixedSize) reappear in every list implementation.

Hardware layers and when they help#

A hardware layer (View.setLayerType(LAYER_TYPE_HARDWARE, null)) renders a view into an offscreen texture once, and then composites that texture. For an animation that only translates, scales or fades a complex view, this turns per-frame drawing into per-frame texture sampling.

The cost is memory: an offscreen texture is a full framebuffer for that view. Using a hardware layer for a large view on a low-memory device can cost more than it saves. It is a targeted optimisation for a specific animation, not a general setting.

Why "just use hardware acceleration" was not enough#

Turning on the GPU path in Android 3.0 did not make every app fast, and the reason is worth understanding: a GPU is not uniformly faster than a CPU. It is dramatically faster at shading many pixels in parallel, and slower at branchy, sequential work.

The workload that changed most was text and large images — both embarrassingly parallel. The workload that changed least was a view hierarchy with hundreds of small, individually-recorded nodes, because the cost was in the bookkeeping rather than the pixels.

That is why the RenderThread mattered as much as the GPU did. It moved the bookkeeping off the thread that handles input, so even a frame that was expensive did not make the app feel unresponsive.

The two kinds of expensive#

A frame is expensive in one of two ways, and the distinction determines the fix.

Throughput-expensive frames do a lot of work: many nodes, many pixels, many draw calls. The fix is to do less — flatten, cache, clip.

Latency-expensive frames wait: on a lock, on a texture upload, on a shader compile. The fix is to move the wait off the frame path or to do it earlier. A shader compiled on the first frame that needs it produces a hitch on that frame only, which is exactly the kind of one-off jank that is hardest to reproduce.

Where RenderNodes stop helping#

A RenderNode is a cache. Caches help when the same thing is drawn repeatedly, and hurt when everything changes every frame.

A video player's surface changes every frame — its display list is just "draw this texture", so the recording is trivial and the GPU does the work. A list of static rows changes only when scrolled, so the cache pays off enormously.

The pathological case is a view whose content changes every frame but whose geometry does not: an animated progress ring, a live chart. Each frame re-records the node, re-uploads geometry, and re-renders. The right fix there is usually not a display-list optimisation at all, but a custom drawing path that keeps the changing part small.

Reading a Perfetto trace for this#

On the UI thread, look for:

  • Choreographer#doFrame — the frame's overall envelope.
  • performTraversals — measure, layout and the sync.
  • DrawFrame on the RenderThread — replay and GPU submission.

If performTraversals is long and DrawFrame is short, the frame is CPU-bound in the view tree. If the reverse, it is GPU-bound. If both are short but the frame is still late, look at the compositor and the display — the app did its job and something downstream did not.

How it evolved#

API 1–API 10Software onlyCanvas rasterised on the UI threadAPI 1
API 11–API 20Hardware accelerationdisplay lists and a GPU path for ViewsAPI 11
API 21 →RenderThreadreplay moves off the UI threadAPI 21
Interface changes16 releases
APIVersionWhat changed
11.0
  • Pull-down notification shade
  • Home screen with widgets
31.5 Cupcake
  • Home screen widgets and folders
92.3 Gingerbread
  • New UI with dark theme and green accents
113.0 Honeycomb
  • The Holo design language arrives
123.1 Honeycomb
  • Resizable widgets
164.1 Jelly Bean
  • Project Butter: vsync-driven, smooth UI
  • Expandable notifications
204.4W KitKat (Wear)
  • Voice-driven card UI
215.0 Lollipop
  • Material Design
  • Notifications on the lock screen
247.0 Nougat
  • Quick Settings tiles
  • Direct Reply in notifications
268.0 Oreo
  • Notification channels
289 Pie
  • Dark theme groundwork
2910
  • System-wide dark theme
3011
  • Conversation notifications and bubbles
  • Media controls in Quick Settings
3112
  • Material You dynamic colour
3313 Tiramisu
  • Themed app icons
  • Runtime notification permission
3616 Baklava
  • Material 3 Expressive
  • Live Updates notifications

The RenderThread's arrival in API 21Android 5.0 is the dividing line. Everything before it was about making the recording-and-drawing loop cheaper; everything after is about moving pieces of it off the critical thread.

Under the hood#

Curiosity corner#

  • The RenderThread has its own looper. It is a normal Android thread with a message queue, which means it can be traced with the same tools as the UI thread.
  • A display list can be replayed at a different size. That is how some resizes avoid a full re-layout: the recorded drawing is scaled rather than re-recorded.
  • Skia's CPU backend never went away. Software rendering still exists, and some views opt into it deliberately.

Try it#

Turn on GPU rendering profiling
adb shell settings put global debug.hwui.profile true
Example output
Profile GPU rendering enabled
Frame times, split by phase
adb shell dumpsys gfxinfo com.example.app framestats
Example output
Draw  Prepare  Process  Execute

Check yourself#

Check yourself

1 / 12

At 120 Hz, how much time does one frame have before it misses the display's deadline?

0 answered · 0 correct

Sources#

Sources

Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.

  1. docsGraphics architecturesource.android.comaccessed 2026-10-27↩
  2. docsRenderersource.android.comaccessed 2026-10-27↩
  3. docsEGL and OpenGL ESsource.android.comaccessed 2026-10-27↩
  4. docsVulkansource.android.comaccessed 2026-10-27↩
  5. docsRendering | App qualitydeveloper.android.comaccessed 2026-10-27↩
  6. docsInspect GPU rendering speeddeveloper.android.comaccessed 2026-10-27↩
  7. docsAndroid tracing with Perfettoperfetto.devaccessed 2026-10-27↩