There’s a specific category of performance issue that can be particularly challenging: an application that shows high resource usage when seemingly idle. On the screen, everything is static. No user input, no visible animations. But under the hood, the CPU is under heavy load. The device may become warm. The logs fill up with warnings about skipped frames and constant garbage collection. In severe cases, this can lead to the application becoming unresponsive.
We were facing this exact scenario. Our application was exhibiting significant performance degradation, including unresponsiveness, even when it appeared to be doing nothing. The symptoms pointed to a persistent, hidden process consuming system resources.
This is the story of that investigation. It’s a journey that starts with a few cryptic log messages and ends with a fundamental shift in how we approach animation — moving from a resource-intensive approach to a GPU-powered solution that runs with near-zero overhead.
The Evidence: Analyzing the Logs
Every technical investigation begins with data. For a mobile app, the logs provide the first critical clues. Ours were painting a clear picture of the problem.
I/Choreographer: Skipped … frames!: This warning, often with high frame counts, was the most direct symptom. It indicated the UI thread was too busy to render new frames in time, causing the “jank” and sluggishness in the user interface.Background concurrent mark compact GC…: This message appeared relentlessly. It meant the Garbage Collector (GC) was working continuously to clean up a large number of objects being created and destroyed. This constant memory churn was a primary source of the CPU load.DeadSystemException: This log entry was the clearest sign of a critical issue. The Android OS was terminating the application due to prolonged unresponsiveness — an Application Not Responding (ANR) error.
The central contradiction was stark: a visually static UI was generating the workload of a highly active one. This told us the problem wasn’t in the visible state of the UI, but in a hidden, ongoing process.
The Investigation: Pinpointing the Cause with DevTools
With the logs pointing to a continuous background task, we turned to our primary diagnostic tool: Flutter DevTools. After ensuring we were using Debug Mode for detailed widget tracking, we found the source of the issue.

The breakthrough came from the Rebuild Stats tab. The data was unequivocal. While most of our widgets showed single-digit rebuilds, one component stood out: widgets from the fade_shimmer package were rebuilding thousands of times in just a few minutes.
This was the root cause. The shimmer effect, intended as a simple loading placeholder, was constantly redrawing itself and creating a major performance bottleneck for the entire application.
The Root Cause: Why setState() Was Inefficient
The problem wasn’t the concept of a shimmer, but how it was animated. The fade_shimmer package used a common but inefficient technique for this use case: a Timer.periodic that repeatedly called setState().
This triggers Flutter’s entire rebuild pipeline, over and over:
setState()Called: The timer fires, marking the widget as needing a rebuild.- Widget Rebuild: The engine destroys the old widget and calls its build() method to create a new one. This generates memory garbage.
- Layout & Paint: The engine must then re-calculate layout information and repaint the pixels.
This entire resource-intensive cycle was running non-stop for every shimmer widget on screen. The constant object creation caused the GC churn, and the heavy rebuild process blocked the UI thread, causing the skipped frames.
+-----------------+ +--------------+
.---> | Timer Fires | ---> | setState() | -----+
| +-----------------+ +--------------+ |
| | (Causes high CPU
| (Repeats multiple | & Memory Churn)
| times per second) v
| +-----------------+ +---------------------+
+---- | Layout & Paint | <--- | Rebuild Widget Tree | <---
+-----------------+ +---------------------+
The Solution: Thinking Like a Painter, Not a Builder
We realized we had an opportunity to optimize by changing our rendering strategy. Instead of continuously rebuilding a widget to change its appearance, a more performant approach is to keep the widget static and just paint a moving effect over it.
This is precisely what Flutter’s ShaderMask is designed for.
The ShaderMask pipeline is far more efficient:
- Build (Once): A static placeholder widget (e.g., a gray
Container) is built a single time. - Animate a Value: An AnimationController updates a simple double value in memory. This is a very cheap operation that does not call
setState(). - Repaint on GPU: The ShaderMask listens to this value and instructs the GPU to repaint a moving gradient directly over the static placeholder. The widget itself is never rebuilt.
This approach isolates the animation work to a simple, highly optimized repaint operation on the GPU, completely avoiding the expensive build, layout, and object churn cycles.
+-----------------------+
| Build Widget (Once) | --+ (Static placeholder UI)
+-----------------------+ |
|
+-------------------------+ +---------------------+
.---> | AnimationController | ---> | GPU Repaints Shader | ---> (Smooth animation on screen)
| | (updates a simple value)| | (No widget rebuild) |
| +-------------------------+ +---------------------+
| |
+---- (Lightweight animation loop, minimal CPU impact) ---------+
Crafting the Performant Shimmer
Given the performance requirements, we built our own self-contained widget. The core is surprisingly simple:
An AnimationController acts as the heartbeat, producing a constantly changing value. We then use an AnimatedBuilder — a widget optimized for this exact scenario — to listen to the controller. When the value changes, AnimatedBuilder rebuilds only the ShaderMask it contains.
Inside the ShaderMask, we create a LinearGradient and use a custom GradientTransform to shift its position based on the controller’s value. This creates the smooth sliding effect, driven directly by the GPU, with minimal impact on the CPU. The result was a stable, performant application with no unexpected background CPU or memory usage when idle.
The key takeaway is to always evaluate the need for setState(), especially in animations. Before you call it, ask: “Am I changing the widget’s structure, or am I just changing how it’s painted?” If the answer is the latter, a rendering tool like ShaderMask or CustomPaint will almost always be the more performant choice.
Confirmation
- Low Rebuild Counts: The “Rebuild Stats” show that your main application widgets (
MainMaterialApp,BlocProvider, etc.) have only been built once. This is excellent. It confirms that the constant, expensive rebuilding caused by the old shimmer has been eliminated. - Correct Animation Widget: The only widget with a high rebuild count is
AnimatedBuilder. This is the correct, highly-optimized widget for handling animations. It is doing its job efficiently without forcing the rest of your UI to rebuild.

The Next Level: Optimizing Shimmers in a List
The ShaderMask solution worked perfectly for individual shimmer instances. However, a new, more subtle bottleneck emerged during testing: applying the shimmer to a list of items. When we displayed a list of 25 placeholder items, each shimmer widget created its own AnimationController.
While vastly more efficient than setState(), running 25 separate AnimationController animations simultaneously still created significant rendering overhead, causing noticeable jank. The problem had shifted from expensive widget rebuilds to the sheer volume of concurrent animations.
The correct architectural pattern is to drive all visible shimmers with a single, shared animation controller. This was achieved with a two-part system:
- A Controller Widget (ShimmerAnimationController): Placed at the top of the list, this widget creates one
AnimationControllerand provides its animation value to all descendants using anInheritedWidget. - A Context-Aware Shimmer: Our new shimmer was refactored to first check if a shared animation controller exists above it in the widget tree. If it does, it uses the shared animation. If not, it creates its own local controller, allowing it to function as a standalone shimmer.
This pattern transforms the rendering workload dramatically.
Old Structure (Many Animations):
- Column
- CommonFadeShimmer (Runs Animation #1)
- CommonFadeShimmer (Runs Animation #2)
- ... (23 more)
New Structure (One Shared Animation):
- ShimmerAnimationController (Runs ONE Animation)
- child: Column
- CommonFadeShimmer (Listens to shared animation)
- CommonFadeShimmer (Listens to shared animation)
- ... (23 more)
This final architecture ensures that whether there is one shimmer or one hundred, the animation overhead remains constant and minimal.
Final Analysis & Secondary Findings
The final logs and DevTools analysis, which tested the optimized list architecture, confirmed the solution was a complete success. The constant garbage collection messages vanished, and the Rebuild Stats showed that only the lightweight AnimatedBuilder was rebuilding — validating that our shared ShimmerAnimationController was driving the UI efficiently while the rest of the widget tree remained static.
The investigation also uncovered two unrelated issues we’ve slated for future work: a significant freeze on app launch due to service initializations blocking the main thread, and a race condition with the Facebook SDK.
This underscores the value of a deep diagnostic dive.
By shifting our animation strategy, we not only fixed the immediate bug but also gained a deeper understanding of Flutter’s rendering pipeline — knowledge that will help us build more resilient and performant applications from the start.
“I’m not a great programmer; I’m just a good programmer with great habits.” — Kent Beck
Final Word 🪅
