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.
"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#
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:
- Walk the RenderNode tree and apply transforms, clips and alpha.
- Convert each drawing operation into Skia calls.
- Let Skia translate those into the active GPU backend.
- Render into the surface's current buffer.
- 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:
| Software | Hardware accelerated | |
|---|---|---|
| Where drawing happens | UI thread, into a bitmap | RenderThread, into a GPU buffer |
| Cost model | proportional to pixels touched | proportional to pixels touched, but parallel and cached |
| Unsupported operations | all Canvas operations | some operations are not supported and are ignored or emulated |
| Text and shapes | CPU raster | GPU, 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.DrawFrameon 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 | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 3 | 1.5 Cupcake |
|
| 9 | 2.3 Gingerbread |
|
| 11 | 3.0 Honeycomb |
|
| 12 | 3.1 Honeycomb |
|
| 16 | 4.1 Jelly Bean |
|
| 20 | 4.4W KitKat (Wear) |
|
| 21 | 5.0 Lollipop |
|
| 24 | 7.0 Nougat |
|
| 26 | 8.0 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 33 | 13 Tiramisu |
|
| 36 | 16 Baklava |
|
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#
- RenderThread.cppthe thread loop and the task queue the UI thread pushes into
- RenderNode.cppthe recorded drawing and its dirty flags
- RecordingCanvas.cppwhere Canvas calls become display-list operations
- RenderNode.javathe public-ish Java handle to a native RenderNode
- SkiaCanvas.cppthe bridge from display-list operations to Skia
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#
adb shell settings put global debug.hwui.profile trueExample output
Profile GPU rendering enabledadb shell dumpsys gfxinfo com.example.app framestatsExample output
Draw Prepare Process ExecuteCheck yourself#
Check yourself
At 120 Hz, how much time does one frame have before it misses the display's deadline?
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.
- docsGraphics architecturesource.android.comaccessed 2026-10-27↩
- docsRenderersource.android.comaccessed 2026-10-27↩
- docsEGL and OpenGL ESsource.android.comaccessed 2026-10-27↩
- docsVulkansource.android.comaccessed 2026-10-27↩
- docsRendering | App qualitydeveloper.android.comaccessed 2026-10-27↩
- docsInspect GPU rendering speeddeveloper.android.comaccessed 2026-10-27↩
- docsAndroid tracing with Perfettoperfetto.devaccessed 2026-10-27↩