NK

Search

Search pages, projects, posts, components, and icons

All articles
Mobile16 min read

Impeller vs Skia: How a Flutter Frame Actually Reaches the GPU

Shader compilation jank, why Impeller replaced Skia rather than tuning it, and the full path a frame takes from Widget to RenderObject to Layer to draw call.

flutterimpellerskiarenderingperformance

Flutter's switch to Impeller was not a performance tuning exercise. It was an admission that one design decision, made early and reasonably, had a failure mode that could not be tuned away.

That decision was to build on Skia, the same 2D graphics library that powers Chrome. Skia is excellent, and it is not the reason your app stuttered the first time you opened a screen. The reason was that Skia generates its shaders at runtime, and a shader that has never been compiled must be compiled before the frame it belongs to can be drawn.

This post is about that failure, why the fix required a different renderer rather than a faster one, and the exact path a frame takes from a widget you wrote to pixels on a display.

Glossary

Everything this post uses, defined before it is used. Skip it if the terms are already familiar, or come back when one of them trips you up.

The one sentence the rest of this post expands: drawing a frame needs small GPU programs, Skia wrote them while you waited and Impeller writes them in advance.

Terms

TermMeaning
FrameOne picture on screen. At 60fps you have about 16 milliseconds to produce each one.
Frame budgetHow long you get per frame before the user sees a stutter.
JankA frame that missed its budget. The visible stutter in an otherwise smooth animation.
ShaderA small program that runs on the GPU, once per vertex or once per pixel, to work out what to draw.
Shader compilationTurning that program's source into something the GPU can run. Slow enough to blow a frame budget.
Shader compilation jankA one-off stutter the first time a new visual effect appears, because its shader did not exist yet.
Shader warm-upBundling a captured list of shaders and compiling them at app startup instead.
RendererThe piece that turns drawing instructions into pixels. Skia and Impeller are both renderers.
RasteriseTurn a description of shapes into actual pixels.
WidgetAn immutable description of a piece of UI. Not the thing on screen.
ElementThe long-lived object holding a widget's place and identity across rebuilds.
RenderObjectThe object that does layout and painting. Expensive to create, so it is reused.
Layer treeThe ordered list of drawing operations, with clips and transforms, handed to the raster thread.
UI threadWhere your Dart runs. Build, layout, paint.
Raster threadWhere the layer tree becomes pixels. A separate budget from the UI thread.
RepaintBoundaryA split in the layer tree, so a repaint stops spreading outward.
ConstraintsThe size limits a parent gives a child during layout.
saveLayerAn offscreen buffer the renderer allocates for effects like partial opacity. Expensive.
TessellationCutting a complex shape into triangles the GPU can draw.
Stencil bufferA GPU scratch area used to mask which pixels get drawn.
OverdrawPainting the same pixel several times in one frame, throwing away the earlier work.
Metal, Vulkan, OpenGLWays of talking to the GPU. Metal is Apple's, Vulkan is the modern cross-platform one, OpenGL is the older one.

Abbreviations

ShortFull formIn plain words
GPUGraphics Processing UnitThe chip that draws pixels
CPUCentral Processing UnitThe chip that runs your Dart code
UIUser InterfaceEverything the user sees and touches
APIApplication Programming InterfaceThe set of calls one piece of software offers another
2DTwo-dimensionalFlat graphics, shapes and text rather than 3D scenes
fpsframes per secondHow many pictures are drawn each second. 60fps is the common target
msmillisecondsA thousandth of a second. A 60fps frame budget is 16.6ms
FAQFrequently Asked QuestionsThe question section near the end

The problem Impeller was built to solve

Every frame your app draws is, at the bottom, a set of GPU programs called shaders: small pieces of code that run per vertex and per pixel. Somebody has to produce them.

If shaders are new to you, the useful image is a recipe card the graphics chip follows. A blurred rounded rectangle with a gradient needs a different card from plain red text. Someone has to write the card before the chip can cook, and writing it takes time you do not have inside a frame.

Why Skia compiled shaders at the worst possible moment

Skia builds shaders on demand. It looks at what you are drawing right now, and assembles a shader that draws exactly that: this blend mode, this gradient, this clip, this filter. The combination space is enormous, so pre-building all of it is not practical. Skia builds what it needs, when it needs it, and caches the result.

The cache is the problem. The first time a particular combination appears, the shader does not exist yet, and compiling it takes time on the order of milliseconds to tens of milliseconds. A 60fps frame budget is 16.6ms. A 120fps budget is 8.3ms.

So the first frame of any new visual effect could blow the budget, and the symptom was specific and recognisable: the app ran smoothly, then hitched exactly once at the moment a new animation, transition or effect first appeared. Never again after that, because the shader was cached. Which made it maddening to reproduce, because your development device had a warm cache within minutes of starting work.

This is shader compilation jank, and it was the most reported performance issue in Flutter for years.

Going deeper: why the workarounds were not enough

Flutter shipped a mitigation: shader warm-up. You recorded the shaders your app used into a file, bundled it, and Flutter compiled them at startup.

It worked, and it was miserable. The capture had to be redone whenever the UI changed. It was device and driver specific, so a file captured on one GPU was not necessarily the right set for another. It moved the cost to startup rather than removing it. And it required developers to think about shader caches at all, which is not a thing a UI framework should ask.

The deeper issue is that this could not be fixed inside Skia's model. If shaders are assembled from an open-ended combination space at runtime, then some combination will always be new. You can make compilation faster. You cannot make it never happen.

What Impeller does instead

Impeller inverts the decision. It uses a fixed, small set of shaders, compiled at build time, and expresses drawing as combinations of those known shaders rather than as generated ones.

That constraint costs flexibility. Impeller cannot generate an arbitrary one-shot shader for an unusual effect. It has to decompose the effect into operations it already has. In exchange, there is no compilation at frame time, because there is nothing left to compile. The jank does not get smaller. It stops existing.

Impeller also targets modern graphics APIs directly, Metal on iOS, Vulkan on Android, rather than going through an abstraction that has to accommodate OpenGL's older model. That allows it to do things Skia's abstraction made awkward, like predictable use of the GPU's tessellation and stencil hardware instead of CPU-side geometry work.

The path a frame takes

Understanding where Impeller sits requires knowing what happens before it, and most Flutter performance advice makes more sense once you can name the stage that is actually slow.

There are four representations of your UI, and they exist for different reasons. The same thing gets described four times, each time in a form better suited to the next stage: the widget is the order you write, the element is the kitchen remembering your table, the render object is the plate, and the layer tree is the tray that goes out to the floor.

Widget: a description, not a thing

A widget is an immutable configuration object. Container(color: Colors.red) does not draw anything and does not exist on screen. It is a description of what you want, cheap to allocate and cheap to throw away, which is why rebuilding widgets is not the disaster new Flutter developers assume it is.

Element: the bookkeeping layer

The element tree is the part most people never touch, and it is where the efficiency comes from. An element is the instantiated widget in place in the tree, holding the identity that survives across rebuilds.

When you rebuild, Flutter walks the new widget tree against the existing element tree and asks, for each position, whether the new widget can update the existing element. Same runtime type and same key means yes, and the element is updated in place. Different type means no, and the subtree is torn down and rebuilt.

This is why const constructors matter. A const widget is canonicalised: the same instance every time. Flutter compares by identity, sees no change, and skips the subtree entirely.

RenderObject: the expensive one

Render objects do layout and painting. They are expensive to create and are kept alive across rebuilds wherever possible, which is the whole point of the element tree sitting between widgets and them.

Layout is a single walk with a specific contract: constraints go down, sizes come up. A parent tells a child how big it is allowed to be, the child decides its own size within those limits, and the parent then positions it. One pass, top down and back up, no iteration.

That contract is also why Row and Column throw unbounded height and width errors. A Column gives its children unbounded vertical space, so a child that sizes itself to its parent has been asked an unanswerable question.

Layer: what actually gets handed to the GPU

Painting does not produce pixels. It produces a layer tree: a description of drawing operations, ordered, with transforms and clips attached.

This is the boundary that matters for performance. Everything up to here runs on the UI thread, in Dart. From here down runs on the raster thread, and the two can be busy independently. A janky app is either building and laying out too much on the UI thread, or asking for something expensive to rasterise. They look identical to a user and have nothing in common as problems.

RepaintBoundary exists at this boundary. It creates a separate layer, so a repaint inside it does not force everything around it to repaint too. Wrapping an animating widget in one means the surrounding static content is not re-rasterised sixty times a second.

Where Impeller takes over

The raster thread receives the layer tree, and this is where the renderers differ.

What Skia did with it

Skia walked the layer tree, and for each drawing operation determined the shader required, looked it up in its cache, compiled it if it was not there, and issued the GPU commands.

The compile step in the middle of that sentence is the entire problem. It sits inside the frame, on the thread that must finish within the budget.

What Impeller does with it

Impeller takes the same layer tree and decomposes it into operations backed by its precompiled shader set. There is no lookup that can miss and no compile step that can run long, so the time a frame takes depends on how much work it contains rather than on whether it has been seen before.

The practical effect is not that frames became dramatically faster on average. It is that the worst frame got much closer to the average one. Consistency is what users perceive as smoothness: a steady 60fps feels better than a mostly 120fps that drops a frame when something new appears.

Impeller also leans on the GPU for work Skia often did on the CPU. Complex path tessellation, for instance, uses stencil buffer techniques rather than CPU-computed triangles, which moves the cost to hardware built for it.

Going deeper: what this costs

Impeller is not strictly better, and it is worth being honest about the trade.

The fixed shader set means unusual effects must be expressed in terms of existing operations, and some are more expensive than a bespoke shader would have been. Early Impeller releases had visible gaps against Skia in specific cases, certain blur and blend combinations were slower, and some paths rendered with small differences. Most of these have closed, and the remaining ones are narrow, but "different renderer" has never meant "identical output".

Impeller also raised the platform floor. It requires Metal or Vulkan, so devices on older Android drivers fall back to an OpenGL path or to Skia. If you support low-end Android broadly, you are shipping to both renderers whether you think about it or not.

Reading your own frames

The pipeline stages map directly onto the tools, which makes diagnosis mechanical rather than speculative.

DevTools' performance view separates UI thread time from raster thread time per frame. That split is the first question worth asking, because it decides which half of everything above you should be looking at.

UI thread over budget means build or layout is too expensive. Look for rebuilds that are larger than they need to be, missing const, expensive work in build(), or layout thrash from deeply nested unconstrained widgets.

Raster thread over budget means the layer tree is expensive to draw. Look for large blurs, saved layers from opacity and clipping, overdraw, and missing repaint boundaries around animating content.

Under Skia there was a third case: a single spike on first appearance, with both threads otherwise healthy. That was shader compilation, and it is the one that Impeller removed. If you still see first-appearance spikes on a current Flutter version, they are worth investigating as something else, because that particular cause is gone.

Key takeaways

  • Skia generated shaders at runtime, so the first frame using a new visual effect paid a compile cost inside the frame budget.
  • Shader warm-up was a workaround, not a fix. An open-ended combination space always has a combination you have not compiled yet.
  • Impeller uses a fixed shader set compiled at build time, which removes the problem by construction rather than making it faster.
  • Four representations, four jobs: widgets describe, elements hold identity across rebuilds, render objects lay out and paint, layers are what the GPU is given.
  • Layout is one pass: constraints down, sizes up. That contract explains most layout errors you will hit.
  • The layer tree is the thread boundary. Everything above it is the UI thread, everything below is the raster thread, and confusing the two wastes most of the time people spend on Flutter performance.
  • Impeller's win is consistency, not raw average speed. The worst frame moved closer to the typical one.
  • It is a trade. A fixed shader set is less flexible, and Impeller needs Metal or Vulkan, so older Android may still be running something else.

FAQ

Do I need to do anything to use Impeller?

On current Flutter versions it is the default on iOS and on Android devices with Vulkan support. The main thing to do is stop shipping shader warm-up files, which now add startup cost for no benefit.

Should I delete my shader warm-up bundle?

If you target Impeller, yes. It exists to pre-compile shaders for a renderer that no longer compiles shaders at runtime.

Is Impeller faster than Skia?

Not reliably on average, and that is the wrong comparison. It is far more predictable, which is what smoothness actually is. A frame that consistently takes 10ms beats one that usually takes 6ms and occasionally takes 40ms.

Why does my app still drop frames?

Because shader compilation was one cause of jank, not the only one. Expensive builds, layout thrash, large images decoded on the wrong thread and heavy blurs all still cost what they cost. Check which thread is over budget first.

Does Impeller change how I write widgets?

No. It sits below the layer tree, and everything above that is unchanged. const constructors, keys, repaint boundaries and cheap build() methods matter exactly as much as before.

What about the web?

Web has its own renderers, CanvasKit, which is Skia compiled to WebAssembly, and the newer Skwasm. Impeller is a native-platform renderer, so this post's specifics do not transfer to Flutter web.

Conclusion

The interesting thing about Impeller is not that it is new. It is that the problem it solves could not be solved where it was.

Skia's runtime shader generation is a good design for a general-purpose 2D library serving many clients with unpredictable needs. It is a bad fit for a UI framework with a hard per-frame deadline, because it trades a rare, large, badly-timed cost for flexibility that a UI framework does not especially need. Flutter spent years treating the symptom before concluding that the constraint had to change.

Which is the more useful lesson than any renderer benchmark: when a performance problem keeps coming back in new forms, the fix is usually not in the code that is slow. It is in the assumption that made it possible.

References

Official documentation for the topics covered here.

Read more

How Flutter Rendering Actually Works goes deeper on the three trees and the layout protocol, and Flutter Is Not Just a UI Framework covers what owning the whole pipeline buys you in the first place.