When Your Flutter App Only Stutters on First Launch: A Complete Guide to Shader Compilation Jank

An animation that ran a buttery 60fps in debug mode suddenly gets reports of “stuttering” on new devices the moment you push a release build to the store. You try to reproduce it on your dev phone and everything’s smooth. QA’s logs don’t show any exceptions either. You slap const on every widget, switch to ListView.builder, wrap things in RepaintBoundary — and the symptom doesn’t budge. If this matches your situation exactly, the culprit is probably not your widget tree at all. It’s likely shader compilation jank.
This post goes beyond generic advice like “use const widgets” or “keep your build methods light.” It’s a field record — cross-checked against source code and official docs — of why this problem is invisible in debug mode and only surfaces on real-device profile/release builds, exactly what to look for in DevTools, and how the commonly-cited SkSL warm-up procedure has actually changed in recent Flutter releases.
Key takeaways
- Shader compilation jank is a stall caused by the GPU having to build shaders/pipelines from scratch, per device, at runtime, the first time it encounters a given draw operation. Once compiled, it never recurs for that device.
- Debug mode is JIT-based, so this issue is masked in a completely different way there, and profile/release mode is disabled outright on emulators/simulators — you must test on a real device.
- A frame shown in dark red in the DevTools Performance view’s Frame chart is the signature of a shader compilation spike.
- The classic SkSL warm-up procedure —
flutter run --profile --cache-skslfollowed by--bundle-sksl-path— was fully removed from the CLI starting with Flutter 3.32 (stable, May 2025). Following those instructions today will just throw an error. - Now that Impeller is the default renderer, a large chunk of this problem has structurally disappeared — but pitfalls still remain, such as custom fragment shaders or the fallback path on older Android devices.
What jank actually means, and what the frame budget means in practice
The official Flutter docs define jank this way: “If a frame takes much longer than usual, the resulting drop makes the animation appear jerky. For example, if a frame takes ten times longer than usual, that frame is likely to be dropped, resulting in a jerky-looking animation.” The key point: it’s the occasional slow frame — not the average frame time — that tanks the perceived experience. The worst frame, not the mean, is the real metric.
- 60Hz displays: per the official docs, each frame needs to finish rendering in “about 16ms” or jank sets in.
- 120Hz displays: Flutter targets 120fps on supported devices, which arithmetically shrinks the frame budget to about 8.3ms (1000ms ÷ 120). With the budget cut by more than half, the same shader compilation stall feels noticeably worse on a 120Hz device.
This budget is spent across two threads:
- UI thread: runs your app code and Flutter framework code on the Dart VM. It builds widgets, does layout, and hands a layer tree of paint commands off to the raster thread. The official docs explicitly warn: “don’t block this thread.”
- Raster thread: takes that layer tree and actually draws it to the GPU. Skia and Impeller run here. You can’t touch this thread directly — if it’s slow, it’s ultimately because of what your Dart code told it to draw. As an aside, this thread used to be called the “GPU thread,” and the name change is still visible today in the help text for the
--trace-skiaflag, which reads “raster thread (formerly known as the GPU thread).”
Shader compilation jank almost always happens on the Raster thread. A clean UI-thread graph paired with a big red spike on the GPU/Raster graph is exactly this problem’s fingerprint.
Why it never reproduces in debug mode
This is where the issue gets misunderstood the most. Flutter’s build modes aren’t just optimization switches — they’re entirely different execution paths.
- Debug mode: the official docs state it’s “compiled for a fast development cycle” and “not optimized for execution speed, binary size, or deployment.” Asserts are enabled, and DevTools can attach for source-level debugging.
- Profile mode: defined as “some debugging capabilities retained, with just enough functionality to profile performance.” On mobile it’s nearly identical to release mode (only tracing and a few service extensions are additionally enabled). And critically: “profile mode is disabled on emulators and simulators, as their behavior doesn’t represent real performance.”
- Release mode: asserts stripped, debugging info stripped, “optimized for fast startup, fast execution, and small package size.”
In other words, debug mode was never optimized for execution speed in the first place, so performance issues either show up differently there or don’t show up at all. On top of that, shader compilation is fundamentally tied to the specific GPU hardware and driver of the device it runs on. The doc comment on Flutter’s ShaderWarmUp class puts it plainly:
“This warm up needs to be run on each individual device, because shader compilation depends on the specific GPU hardware and driver a device has. The engine cannot precompute this at engine compilation time because the engine is device agnostic.”
The same doc comment gives a concrete figure for how long compiling a single shader takes: “the compilation can be slow (20 to 200ms).” That means a screen with just five or six first-time-seen combinations of gradients, blurs, shadows, and custom ShapeBorders can arithmetically produce a raster spike in the 100–1000ms range (this is purely a theoretical range from multiplying the documented per-shader compile cost — not a measured figure). That’s dozens of 16ms frame budgets (at 60Hz) consumed in one go, which to the user looks like “the screen freezes for a moment and then resumes.”
And this compilation happens only per device, and only the first time that device encounters a given draw operation. Your own well-worn test device may have its cache already warmed up from opening that screen repeatedly (though that cache is wiped on app reinstall, update, or when the OS clears its cache) — but a device that just downloaded the app fresh from the store is “cold,” meeting every shader for the first time. This is exactly why “it only happens on new devices” isn’t a reproducibility fluke — it’s a precisely predictable, structural outcome.
Finding the real cause in the DevTools Performance view
Step 1: Always run in profile mode, on a real device
flutter run --profile
Profile mode itself is disabled on emulators/simulators, so you need to connect an actual device — ideally something similar in spec to whatever reported the issue, and ideally a new device.
Step 2: Look for dark-red frames in the Frame chart
Opening the Performance tab in DevTools shows a paired UI-thread bar and Raster-thread bar for each frame. The official docs explain it this way: “Frames that perform shader compilation are displayed in dark red.” That color is the visual fingerprint of shader compilation jank. Ordinary jank (build/layout overload) tends to spike the UI-thread bar first, whereas shader compilation jank shows up as a spike of hundreds of milliseconds on the Raster-thread bar alone.
Step 3: Check the call stack in Timeline events
Click the suspect frame and drill into the Timeline events tab to look for the pattern the ShaderWarmUp class docs point to: a long-running GrGLProgramBuilder::finalize call appearing mid-animation, with a parent call named something like FillRectOp or CircularRRectOp — an XyzOp-shaped draw operation. If you see that, it means that draw operation triggered a fresh shader compilation. (These trace names are Skia-backend specific; Impeller surfaces pipeline-creation events under different names.)
Step 4: Turn up the resolution with Enhance tracing
The “Enhance tracing” dropdown on the Performance tab has three options:
- Track Widget Builds: shows
build()method events and widget names on the timeline - Track Layouts: shows layout events for render objects
- Track Paints: shows paint events for render objects
That said, as the official docs warn, “enabling these options can affect frame timing.” Turn them on only to narrow down the cause, and turn them off again when measuring actual numbers. In the same tab’s “More debugging options,” toggling off Clip / Opacity / Physical Shape layers individually lets you isolate whether excessive clipping or shadow effects are triggering a new pipeline.
Step 5: Force reproducibility with --trace-skia and --purge-persistent-cache
flutter run --profile --trace-skia
The help text describes --trace-skia as “useful for debugging the raster thread (formerly known as the GPU thread),” and it’s off by default because of the overhead it adds. But the flag that matters most in practice is a different one:
flutter run --profile --purge-persistent-cache
This flag is still present in current stable Flutter, and its help text reads exactly: “Removes all existing persistent caches. This allows reproducing shader compilation jank that normally only happens the first time an app is run, and reliably testing compilation jank fixes (e.g., shader warm-up).” In other words, it’s the official way to force a “brand-new device, first launch” state even on a test device you’ve been iterating on for months. Running QA or regression tests repeatedly without this flag only shows you a warmed-up cache, and you can end up missing the problem entirely.
The SkSL warm-up cache: the classic fix, and the pitfall it hides today
Most tutorials floating around the internet walk you through the following procedure at this point.
# 1. Run in profile mode and record the shaders actually encountered
flutter run --profile --cache-sksl
# 2. Exercise the app as thoroughly as possible — trigger every animation
# and transition you can — then quit
# → produces a .sksl.json cache file under build/
# 3. Bundle that cache into the release build
flutter build apk --release --bundle-sksl-path flutter_01.sksl.json
flutter build appbundle --release --bundle-sksl-path flutter_01.sksl.json
flutter build ios --release --bundle-sksl-path flutter_01.sksl.json
This procedure existed to work around a structural problem from the Skia era: the GPU compiling never-before-seen shaders at runtime on a new device. By capturing the actual combination of shaders used during a real run and baking that cache into the build, the app could warm everything up in one batch at startup, eliminating mid-animation stalls.
Here’s the practical trap. Running the commands above as-is on recent Flutter simply doesn’t work. There’s a real GitHub issue (flutter/flutter#171585) documenting a developer on Flutter 3.32+ trying to use --bundle-sksl-path and getting the error Could not find an option named '--bundle-sksl-path'. Checking the Flutter repo’s commit history confirms exactly why. A commit merged on February 10, 2025 (33a4c95de07, “remove SkSL bundling and dump skp on compilation”) stripped out all SkSL-bundling-related options from flutter run, flutter build apk/appbundle/ios, and flutter drive. The commit message explains:
“SkSL precompilation was only ever meaningful on iOS to begin with. On other platforms it wasn’t recommended, since Skia generates shaders per target architecture and the cache could be invalid on a different device. And now, iOS can no longer use Skia at all.”
This change has shipped since Flutter 3.32.0 (stable, May 2025). Checking a locally installed Flutter 3.44 SDK’s flutter run --help -v, flutter build apk --help -v, and flutter build web --help -v output confirms it: the strings cache-sksl and bundle-sksl-path don’t appear anywhere. In short, the advice to “build an SkSL warm-up cache and bundle it” is simply no longer an executable procedure on current Flutter. The latest version of the official docs (docs.flutter.dev/perf/rendering-performance) has replaced its mobile-specific shader compilation jank advice with a single sentence: make sure you’re using Impeller, Flutter’s default renderer.
So what should you actually do if you hit this today
- Leave Impeller enabled. Unless it’s explicitly turned off with
--no-enable-impeller, current Flutter already defaults to Impeller. As explained below, Impeller structurally does the job the SkSL cache used to do. - The
ShaderWarmUpAPI is still in the framework. Registering a customShaderWarmUpsubclass onPaintingBinding.shaderWarmUpbefore callingrunApplets you pre-draw representative draw operations to an offscreen canvas at app startup, shifting the compile cost into startup latency instead of mid-animation. That said, the API’s own documentation is written in terms of Skia-era concepts likeGrGLProgramBuilderand--trace-skia, so it doesn’t guarantee the same deterministic effect on the Impeller path. - For legacy projects, the old procedure still works. If you’re pinned to a pre-3.32 Flutter version, or you explicitly force
--no-enable-impelleron Android to keep the older Skia/OpenGL path,--cache-sksl/--bundle-sksl-pathstill function within that version. That said, continuing to depend on this path without a migration plan isn’t recommended.
What changed with Impeller, and what limits remain
Impeller doesn’t so much “fix” shader compilation jank as it relocates when the problem happens. The official docs list four design goals:
- Predictable: compilation and caching decisions are locked in ahead of runtime
- Instrumentable: graphics resources are labeled for easy profiling and capture
- Portable: shaders are written once and translated into backend-specific formats (Metal/Vulkan/GLES, etc.)
- Modern & concurrent: leverages modern graphics APIs and spreads work across multiple threads
Current platform status looks like this (per docs.flutter.dev/perf/impeller):
| Platform | Status |
|---|---|
| iOS | Impeller is the only supported renderer. There’s no falling back to Skia |
| Android | enabled by default on API 29+ (since Flutter 3.27). Devices below API 29 or lacking Vulkan support automatically fall back to Impeller’s legacy OpenGL backend |
| macOS | opt-in via the --enable-impeller flag; the opt-out itself is slated for removal in a future release |
| Web | still uses Skia (CanvasKit). Future Impeller adoption has been mentioned as a possibility |
One curious detail: Flutter’s own CLI help text still carries a stale line. Run flutter run --help -v and check the --enable-impeller description — it still reads “Impeller is the default renderer on iOS. On Android, Impeller is available but not the default.” Tracing the addEnableImpellerFlag function in the flutter_tools source via git blame shows this line was written in March 2023, right when Impeller was first introduced, and has never been updated since. Since the official docs and release notes accurately reflect the actual default change, this CLI help text should be read as a stale, abandoned description string, not actual behavior. Even a small inconsistency like this is enough to make you second-guess “wait, is Impeller actually on in my project?” in practice.
The real mechanism behind how Impeller eliminates jank
The core idea is that most shaders are precompiled offline at engine build time. Digging one level deeper, the Impeller engine source (shell/common/switch_defs.h, common/settings.h) has an internal switch called impeller-lazy-shader-mode, with a comment explaining:
“Whether to delay the initialization of all Pipeline State Objects (PSOs) needed for the Impeller backends. Defaults to false.”
In other words, by default (false), Impeller eagerly initializes every PSO it needs at app startup. The manual work developers used to do by hand-writing a ShaderWarmUp — shifting compile cost from mid-animation to startup — is something Impeller now does by default at the engine level. Setting the io.flutter.embedding.android.ImpellerLazyShaderInitialization metadata to true in Android’s AndroidManifest.xml delays this initialization to make cold start a bit faster — but at the cost of potentially reintroducing an SkSL-era-style stall the first time a shader is actually used. The correct way to think about this isn’t “Impeller means no jank, period” — it’s that the default trade-off has changed.
Limits that still remain
- Custom fragment shaders:
.fragassets registered viashaders:inpubspec.yamlare compiled offline into backend-specific formats byimpellercat build time. That said, uncommon blend modes,BackdropFilter, orPlatformViewcompositing combinations can fall outside the precompiled pipeline set, occasionally leaving a first-use stall. The Flutter team asks that regressions like this be filed as issues with an[Impeller]prefix. - Older Android without Vulkan support: Impeller’s OpenGL fallback path isn’t as optimized as the Vulkan path.
- Web: still CanvasKit-based, so Skia-era characteristics persist.
Verifying the improvement with before/after data
Whether a fix actually helped should be confirmed with numbers, not a gut feeling. Pick one reproducible scenario (e.g., launch the app → navigate to a detail screen heavy with gradients and shadows → scroll a list three times). Run this scenario under each of the two conditions below and compare the frame list in the DevTools Performance tab.
- Before the fix, with the cache purged: run with
flutter run --profile --purge-persistent-cacheto reproduce “brand-new device, first launch” - After the fix, same conditions: apply your Impeller check/warm-up measures, then run again with
--purge-persistent-cache
What you’re comparing isn’t the average — it’s the worst-frame raster time. Before the fix, certain frames should exceed the budget (16ms or 8.3ms) by several times over and show up as dark red; after the fix, confirm in the Frame chart that the spike disappears in the same scenario and every frame stays within budget, and confirm it at the call-stack level in Timeline events too. If you want to wire a performance regression test into CI, automate the same scenario with flutter drive, and make sure to force --purge-persistent-cache on every run — otherwise a warmed-up cache can cause you to miss a real regression.
Checklist
- Did you try reproducing the issue only in a profile or release build on a real device (it’s invisible on emulators/in debug mode to begin with)?
- Did you confirm a dark-red frame on the Raster thread in the DevTools Performance tab?
- Did you test by forcing a “brand-new device, first launch” state with
--purge-persistent-cache? - Did you confirm Impeller is actually enabled on your current Flutter version (i.e., it wasn’t disabled via
--no-enable-impeller)? - Are you aware that
--cache-sksl/--bundle-sksl-pathwere removed starting with Flutter 3.32? - If any of your screens use custom
.fragshaders or unusual blend modes/BackdropFilter, are you treating those as separate suspects? - Did you compare before/after using worst-frame raster time as the metric?