Recently, a client faced an issue: on iPhone 13, the app showed 58–60 FPS on most screens, but one screen with a custom UICollectionViewLayout consistently dropped to 42–45 FPS while scrolling. We launched Instruments and discovered that 14ms out of 16 per frame were spent on layoutAttributesForElementsInRect — the method recalculated all cell positions without caching. Classic: UI rendering doesn't slow down "in general"; it slows down at a specific place for a specific reason. After implementing a simple cache, FPS returned to 60, and the client got a smooth interface in one day. UI rendering speed optimization requires a systematic approach.
Rendering stutters are among the most insidious problems because they are immediately visible to the user but take a long time to diagnose. The FPS metric says "bad", but exactly where needs to be dug into. We work with data, not guesses.
Why does the main thread block rendering?
The golden rule is 16 ms per frame (60 FPS) or 8 ms (120 Hz on Pro devices). Anything that runs on the main thread beyond this blocks rendering. Typical culprits:
On iOS: synchronous CoreData work via viewContext directly in cellForItemAt, decoding UIImage without preparingForDisplay(), NSAttributedString with size calculation in sizeForItemAt without caching.
On Android: blocking I/O in onBindViewHolder, Bitmap.decodeResource() on the main thread, heavy Drawable animations via AnimationDrawable on budget devices with Mali GPU.
A special case is the measure/layout pass. On Android Jetpack Compose, a ConstraintLayout inside LazyColumn with deep nesting triggers two full measure passes per cell. On a complex list with 50+ items, this is noticeable even on a Pixel 7.
How to diagnose rendering problems?
We start diagnostics by profiling on a real device. On iOS we use Xcode Instruments (Core Animation template), on Android — GPU Inspector or Android Studio Profiler. For Flutter — flutter run --profile with DevTools Performance overlay. We collect a baseline: average FPS, number of janky frames, frame time distribution. If the average FPS is below 55, we look for blocking points.
Why is the main thread the main enemy of smoothness? – UI rendering speed optimization
Every operation on the main thread beyond 16 ms (or 8 ms for 120 Hz) causes a frame drop. For example, calling sd_setImageWithURL: without the SDWebImageAvoidAutoSetImage flag consumes 8 ms on the main thread — we saw this on a project where FPS dropped from 55 to 47. Replacing it with the correct option restored smoothness.
What is overdraw and how to find it?
Overdraw is when a single pixel is drawn multiple times per frame. On Android, enable it via "Developer Options → Show GPU Overdraw": blue = 1×, green = 2×, pink = 3×, red = 4×+. A red screen on a budget Xiaomi with Adreno 610 guarantees jank.
A common cause is nested ViewGroups with opaque backgrounds, where each layer draws its background on top of the previous one. On iOS, the equivalent is a CALayer with opaque = false where transparency is not needed, or shouldRasterize without explicit rasterizationScale.
UI rendering speed optimization: step-by-step plan
What is included in the work
- Audit with a report: profiling screenshots, list of problematic spots, baseline metrics.
- Code changes with comments for developers.
- Recommendations on architecture and tools for long-term FPS maintenance.
- Monitoring — integration of Firebase Performance or a custom FPS monitor.
- Guarantee to achieve the target FPS within 30 days after delivery.
How we do it: a specific case
A typical iOS project scenario: a client complains about "lag in the feed." We open Time Profiler, record 5 seconds of scrolling. In the call tree, we immediately see: [SDWebImage sd_setImageWithURL:] consumes 8 ms on the main thread because someone removed options:SDWebImageAvoidAutoSetImage and images are applied synchronously after loading. One flag — and FPS went from 47 to 59.
On Android, there was a case with RecyclerView + DiffUtil: the developer called submitList() from ViewModel, but DiffUtil worked on the main thread (used ListAdapter without AsyncListDiffer). On a list of 200 items, the diff took ~18 ms. We moved the diff computation to a background thread via AsyncListDiffer — the problem disappeared. AsyncListDiffer is 3 times faster than synchronous DiffUtil on lists of 500 items.
Specific tools and techniques
iOS:
-
CADisplayLink+ custom FPS monitor in debug builds for continuous monitoring -
UIView.setNeedsLayout()vsUIView.layoutIfNeeded()— understanding the difference is critical during animations -
drawRect:is almost always replaced withCALayersublayers — Core Animation renders them on the GPU without CPU involvement -
UIGraphicsImageRendererinstead of the outdated UIGraphicsBeginImageContextWithOptions for offscreen rendering - Prefetching via UICollectionViewDataSourcePrefetching — decode images before the cell appears on screen
Android / Compose:
-
Modifier.graphicsLayer {}for hardware-accelerated transformations instead of software ones -
remember {}andderivedStateOf {}— prevent unnecessary recompositions -
key()in LazyColumn — without it, Compose cannot reuse nodes when the list changes -
Bitmap.Config.RGB_565instead of ARGB_8888 where alpha is not needed — half the GPU memory
Flutter:
-
RepaintBoundaryaround widgets that frequently repaint independently -
constconstructors — widget is not recreated on parent rebuild -
flutter run --profile+ DevTools → Performance overlay — essential before release
Case from our practice: 120 Hz on iPad Pro
Our client made a custom animation via UIViewPropertyAnimator with preferredFrameRateRange. The animation ran at 60 FPS instead of 120. It turned out that one CALayer had shouldRasterize = true without an explicit rasterizationScale = UIScreen.main.scale * 2. Core Animation limited the entire subtree to 60 FPS due to the mismatch in rasterization scale. After the fix, the animation ran at 120 FPS with a noticeable difference in feel. The Core Animation Programming Guide recommends always explicitly setting rasterizationScale for rasterized layers.
Comparison of profiling tools
| Tool | Platform | Typical usage | Complexity |
|---|---|---|---|
| Xcode Instruments (Core Animation) | iOS | FPS measurement, detecting overdraw and main thread blocking | Medium |
| Android GPU Inspector | Android | Frame tracing, GPU load analysis | High |
| Android Studio Profiler (Rendering) | Android | Quick frame time view and recommendations | Low |
| Flutter DevTools Performance | Flutter | FPS overlay and timeline with rebuilds | Low |
Work stages
- Audit — record sessions in Instruments / Android Profiler, collect baseline FPS, janky frames, frame time.
- Analysis — identify bottlenecks: main thread blocking, overdraw, unnecessary layout passes.
- Fixes — iteratively, with measurements after each change.
- Regression run — verify that the fix hasn't broken adjacent screens.
- Monitoring — integrate Firebase Performance or a custom FPS monitor for production tracking.
We estimate the scope after the audit — sometimes the problem is solved in a day, sometimes a custom layout needs rewriting. We have 8+ years of experience in mobile development and have completed over 50 UI optimization projects. Our clients save up to 40% of the budget on improvements compared to full code rewrites.
Timeframe estimates
Point fix (one screen, clear cause): 1–3 days. System audit and optimization of several screens: 1–3 weeks. If the problem is in architectural decisions (incorrect use of main thread throughout the app): allocate 3–6 weeks with phased migration.
Get a consultation for your project — we will estimate the scope and offer the optimal solution. Contact us to start the audit.







