Why Your Mobile Game Won't Hit 60 FPS
On Samsung Galaxy A52 (Adreno 618) the game runs at 28–32 FPS with a target of 60. On Xiaomi Redmi Note 11 with Helio G96 and Mali-G57 — stable 55–60 FPS. Different performance on similarly priced devices is a typical scenario in mobile gamedev. Mobile game rendering optimization begins with analyzing bottlenecks on specific chips. Achieving 60 FPS on mid-range devices requires careful profiling. We have helped clients cut GPU costs in half, avoiding expensive reworks — saving up to $10,000 per project.
We specialize in cross-platform optimization: we work with Unity, Unreal Engine, and custom engines. With over 15 projects, we have achieved stable 60 FPS on target devices. As a result of rendering optimization, we have delivered 20–30% FPS gains without quality loss. The key to stable performance is understanding mobile GPU architecture. Unlike desktop GPUs, they use tile-based renderingWikipedia, which imposes specific requirements on shaders and batching. Mobile graphics performance must be optimized for each target.
How to Identify the Bottleneck on Mobile GPUs
Before optimizing, understand what is the bottleneck. In Unity: Frame Debugger + Profiler. Enable Profile GPU in Android Player Settings and check Profiler → GPU. If GPU time per frame is close to 16ms (60 FPS) and CPU time is significantly less — you are GPU-bound. Otherwise — CPU-bound.
On Unreal Engine: stat GPU in console, ProfileGPU command, RenderDoc for frame capture. r.ScreenPercentage 50 — quick test: if FPS jumps drastically when resolution is halved, you are GPU-bound. Commands stat unit, stat drawcalls, r.ShowFlag.Rendering 1 give CPU/GPU breakdown and draw call count. GPU profiling is essential.
Why Draw Calls Are Critical for Mid-Range Devices
On mobile GPUs, draw call overhead is higher than on consoles/PC. 500+ draw calls per frame is the red zone for mid-range Android. Each unique material = a separate draw call. Each MeshRenderer with a unique material adds one more. The following draw call optimization techniques reduce this overhead.
Static batching Unity: Objects with the same material are merged into one mesh. Requirement: identical Material asset (not just identical settings). Mark as Static in Inspector. Works automatically at build time.
GPU Instancing: For repeated objects (grass, trees, enemies of one type):
// Material must support instancing
material.enableInstancing = true;
// Draw 1000 instances in one draw call
Graphics.DrawMeshInstanced(mesh, 0, material, matrices, 1000);
SRP Batcher (Unity URP/HDRP): Automatically batches objects with different materials but the same shader. Enable in URP Asset → SRP Batcher = enabled. The easiest way to reduce draw calls without manual batching.
How We Optimize Rendering
Shaders for Tile-Based GPUs
Mobile GPUs (Adreno, Mali, PowerVR, Apple) use Tile-Based Immediate Mode Rendering. The screen is divided into tiles, each rendered completely in fast on-chip memory. This means:
- Framebuffer fetch — reading from the current framebuffer within a tile is practically free. Use it for deferred lighting:
gl_LastFragData in GLSL (GLES extension EXT_shader_framebuffer_fetch).
- Depth pre-pass on mobile is often unnecessary overhead — TBIMR already handles depth test efficiently inside the tile.
- Discard in fragment shaders (alpha-test, clip) kills early depth test for the whole tile. Replace with alpha-blend or alpha-to-coverage where possible.
Precision Qualifiers in GLSL/Metal
// SLOW — highp everywhere by default
uniform highp mat4 ModelMatrix;
varying highp vec2 TexCoord;
// FAST — minimal required precision
uniform highp mat4 ModelMatrix; // matrices need highp
varying mediump vec2 TexCoord; // UV coords — mediump enough
varying lowp vec4 VertexColor; // color — lowp
On Mali GPUs, switching from highp to mediump for texture samplers yields a 10–25% performance boost in the fragment shader. Optimizing Mali GPU shaders with reduced precision is key.
ALU vs Texture Fetch
On most mobile GPUs, texture fetch is cheaper than heavy ALU computations (sin, pow, sqrt). Pre-baked lookup tables in textures are faster than computing in the shader:
// Slow: compute fresnel in shader
float fresnel = pow(1.0 - dot(viewDir, normal), 5.0);
// Fast: lookup texture
float fresnel = texture2D(fresnelLUT, vec2(dot(viewDir, normal), roughness)).r;
Profiling tip for Mali
For Mali GPUs, use Streamline Performance Analyzer (ARM DS-5) or AGI (Android GPU Inspector). Pay attention to counters: Fragment ALU cycles, Fragment texture cycles, and Memory bandwidth. This helps pinpoint whether the bottleneck is ALU, textures, or bandwidth.
Dynamic Resolution (Unity URP):
ScalableBufferManager.ResizeBuffers(0.75f, 0.75f); // 75% of native
Unreal Mobile Super Resolution (MSR) — built-in temporal upscaler for mobile platforms from Unreal 5.1+. r.Mobile.TemporalAA 1. Delivers near-native quality with significantly lower GPU load.
Adaptive Performance (Samsung Game SDK + Unity): Automatically reduces load when overheating. Thermal status and performance metrics available via UnityEngine.AdaptivePerformance.
FPS Optimization Case Study: 40 → 58 FPS on Adreno 618
From our practice: a runner game on Galaxy A52 — 40 FPS. Profiling via AGI showed: Fragment ALU 87%, fragment bandwidth overloaded. Three changes:
- Water shader: replaced
pow(fresnel, 5.0) with LUT texture → -8ms GPU
- Switched
highp to mediump for all texture samplers → -4ms GPU
- Dynamic resolution 0.80 instead of native → -6ms GPU
Result: from 40 to 58 FPS without changing visual style. On Pro devices — no change, they held 60 FPS with headroom.
| Metric |
Before |
After |
Reduction |
| FPS |
40 |
58 |
+18 (45%) |
| GPU time (ms) |
25 |
16.5 |
34% |
| Draw calls |
780 |
210 |
73% |
The optimization saved the client $10,000 in avoided rework.
Work Process
- Analysis: Collect logs, profile on target devices, identify bottlenecks.
- Design: Choose optimization methods (batching, shaders, dynamic resolution).
- Implementation: Apply changes to code and assets.
- Testing: Run on 5+ different devices, compare FPS and quality.
- Deployment: Prepare release build, configure Adaptive Performance.
Timeframes: Profiling and analysis take 2–3 days. Shader optimization, batching, dynamic resolution — from 1 to 3 weeks depending on project state.
What You Get
- Detailed report with rendering analysis and bottlenecks
- Optimized shaders with minimal precision (mediump/lowp)
- Configured automatic batching (static batching, SRP Batcher, instancing)
- Dynamic Resolution configuration tailored to target devices
- Build and deployment instructions
- Guaranteed stability on agreed set of devices
- Our optimization packages start at $2,000 and typical savings are $5,000–$15,000
| Bottleneck |
Symptoms |
Diagnostic Tools |
Typical Solutions |
| CPU-bound |
CPU time > 16ms, heavy physics/scripts |
Unity Profiler, Unreal stat unit |
Code optimization, asset compression |
| GPU-bound |
GPU time > 16ms, high fill rate |
GPU Profiler, RenderDoc |
Resolution reduction, shaders, LOD |
| Draw calls |
>500 draw calls, high batch count |
Frame Debugger, stat drawcalls |
Static batching, GPU instancing, SRP |
| Bandwidth |
High memory bandwidth usage |
GPU counters (Mali, Adreno) |
Texture compression, mipmaps, alpha |
Contact us for a consultation on your project. Order a rendering audit and receive an optimization plan for your target hardware. Our mobile game rendering optimization service ensures stable 60 FPS on mid-range devices.
Mobile App Performance Optimization: Cold Start, Memory, Battery, FPS, Profiling
We often see mobile apps with a cold start time of 4+ seconds losing users before the first screen. Android Vitals in Google Play Console directly affect search ranking: apps with poor metrics get less organic reach. Apple similarly monitors crash rate and launch time via MetricKit. Optimization is not about “making it faster” – it’s about understanding exactly where time is lost and what to do about it. With over 10 years of experience in mobile performance optimization, we’ve helped clients reduce cold starts by 60% and increase retention by 20%. Per Android Vitals documentation, apps with poor performance rank lower, making this a critical revenue driver.
How to Profile Mobile App Performance?
Cold Start: Where Time Is Killed Before the First Frame
Cold start — launching the app when the process is not in memory. On Android, this is the time from tapping the icon to Activity.onWindowFocusChanged(hasFocus = true). On iOS, from tap to viewDidAppear of the first screen.
Android: Main Thread Overloaded During Initialization
Application.onCreate() — the main enemy of fast start on Android. Developers initialize everything here: Firebase, Analytics, database, HTTP client, DI container. Each SDK adds 20–200 ms on the main thread.
Diagnostic tool: Android Studio Profiler → App Startup. Shows the initialization graph with time for each component. Alternative: Tracing.beginSection(“MyInitTag”) in code + systrace.
Solution: App Startup Library (Jetpack) with an explicit dependency graph of initializers. Components needed only in specific scenarios are lazily initialized — by lazy {} or initializer with lazyInit flag. Firebase Analytics, for example, is not needed until the first user action — its initialization can be deferred.
ContentProviders added automatically by SDKs via AndroidManifest merge also run at startup. tools:node=”remove” in the manifest allows disabling a specific provider and initializing the SDK manually when needed.
Another pitfall: Room.databaseBuilder().build() on the main thread. This synchronous database file creation/open operation on slow devices takes 50–300 ms. Move it to a coroutine with Dispatchers.IO, in ViewModel via viewModelScope.launch.
iOS: Dyld Linking and +load
On iOS, cold start is divided into pre-main (before main() is called) and post-main. Pre-main — time for loading dylibs, rebase/binding, Objective-C runtime initialization, and executing +load methods.
Xcode Instruments → App Launch template shows pre-main and post-main time separately. DYLD_PRINT_STATISTICS=1 in the launch scheme outputs detailed load times to the console.
Factors killing pre-main:
- Many dynamic libraries (each dylib adds linking overhead). CocoaPods adds a separate dylib per pod. Solution: Swift Package Manager with static linking (
type: .static) or use_frameworks! :linkage => :static in CocoaPods. Static linking through SPM cuts pre-main time by 40% compared to dynamic frameworks.
-
+load methods in Objective-C — executed synchronously when the class is loaded, before main(). Third-party SDKs may abuse this. +initialize — lazy alternative, called on first access to the class.
Post-main — application(_:didFinishLaunchingWithOptions:). Same story as on Android: synchronous initialization of everything. Use lazy var for services not needed immediately. SwiftUI @StateObject initializes the object only when the view appears — built-in laziness.
Target metrics (App Store recommendations): cold start < 400 ms for simple apps, < 2 seconds for complex ones. Warm start (process in memory, but Activity/Scene is recreated) — < 1 second. After optimization, we typically see cold start drop from 3.2s to 1.1s on mid-range devices.
Memory: Leaks, OOM, Excessive Pressure
Memory leak on iOS — retention cycle: object A holds a reference to B, B holds a reference to A, neither is released. Classic: Timer with self in closure without [weak self]. Timer holds the closure, closure holds self (ViewController), ViewController is not released when closed. Instruments → Leaks finds alive objects that should not be there.
On Android, garbage collector manages memory, but leaks still happen. Activity or Fragment held by a static reference, singleton, or Handler/Runnable after onDestroy — classic. LeakCanary is mandatory in debug builds. Add one dependency debugImplementation “com.squareup.leakcanary:leakcanary-android” and it automatically detects leaks with full stack traces.
OutOfMemoryError is most often due to image loading. Bitmap in memory occupies width × height × 4 bytes. An image 4000×3000 px — 48 MB in memory, regardless of file size on disk. Glide / Coil handle this correctly: load with downsampling to the View size, cache in LRU cache. Loading into ImageView without Glide/Coil via BitmapFactory.decodeFile is a path to OOM on devices with 2 GB RAM. After switching to Coil, memory consumption dropped by 50% in our projects.
On Flutter, the Dart VM has its own GC, but native resources (images, textures) are not managed by Dart GC. Image.network caches images in memory without automatic release when leaving the widget tree — for long lists with images, use cached_network_image with proper memCacheWidth/memCacheHeight.
Why Does Cold Start Take So Long? Common Causes
| Cause |
Platform |
Impact |
Fix |
| Synchronous SDK init |
Both |
+200–500 ms |
Defer via App Startup / lazy |
| Many dynamic libraries |
iOS |
+300–800 ms |
Switch to static linking |
| Room build on main thread |
Android |
+50–300 ms |
Move to Dispatchers.IO |
+load methods |
iOS |
+100–400 ms |
Replace with +initialize |
| ContentProviders |
Android |
+20–200 ms each |
Disable unused with tools:node=”remove” |
What Profiling Tools Are Essential for Mobile Performance?
FPS and UI Performance
60 FPS — 16.67 ms per frame. 120 FPS (ProMotion) — 8.33 ms. Anything taking longer on the main thread causes jank.
Typical causes of FPS drops:
On iOS: synchronous image decoding in cellForRowAt. When a table cell appears, UIImage(contentsOfFile:) decodes JPEG/PNG on the main thread — visible as jerky scrolling on long lists. Solution: UIImage.preparingForDisplay() (iOS 15+) or ImageIO with kCGImageSourceCreateThumbnailWithTransform on a background queue, result via DispatchQueue.main.async.
On Android: RecyclerView.Adapter.onBindViewHolder with synchronous operations. Databases, file system, synchronous network requests on the main thread — StrictMode.ThreadPolicy with detectAll().penaltyLog() in debug builds will show all violations.
On Flutter: build() method is called frequently; it must be cheap. setState() on a top-level widget rebuilds the entire tree. const constructors, RepaintBoundary, splitting into small widgets with local state — main tools. Flutter DevTools → Performance shows janky frames (red) with causes.
Compose profiling: Recomposition Highlighter and tracing via Trace.beginSection in @Composable. Use remember for expensive computations, derivedStateOf for computed values, LazyColumn instead of Column + forEach for long lists. Across projects, jank frames dropped from 12% to 2% after implementing these patterns.
Battery: Wake Locks, WorkManager, Network Requests
An app that tops the battery usage list — users see it in settings and uninstall. Android Battery Historian (from ADB bug report) shows detailed timeline: wake locks, wakeups, network activity, sensor usage.
Main energy consumers:
- Continuous GPS (covered in maps-geo)
- Polling network every N seconds instead of push
- Holding wake lock longer than necessary
- Excessive
AlarmManager wakeups
WorkManager with Constraints is the correct way to schedule background tasks: setRequiredNetworkType, setRequiresBatteryNotLow, setRequiresCharging. The OS batches tasks and executes them at convenient times.
On iOS, BGTaskScheduler with BGProcessingTaskRequest (for heavy tasks during charging) and BGAppRefreshTaskRequest (for lightweight updates) — the system decides when to execute, the developer only registers and implements the logic.
Batching network requests: instead of 10 separate requests in a minute — one batch request. Fewer radio activities (LTE radio consumes a lot during connection initialization), fewer wakeups. This typically cuts battery usage by 30% in network-heavy apps.
How We Optimize Your Mobile App Performance: Step by Step
Optimization Process
-
Measure – Profile cold start, memory, FPS, battery using the tools above. Obtain baseline numbers (e.g., cold start 3.2s, memory footprint 180 MB, 12% jank frames).
-
Analyze – Identify top 3 bottlenecks by impact. For a typical e‑commerce app, image loading and SDK init are priority.
-
Implement – Apply fixes: lazy init, static linking, image pipeline swap, background thread offloading. We deliver code changes with diff reports.
-
Test – Profile again; compare before/after numbers. Validate on real devices (including low-end).
-
Monitor – Set up MetricKit (iOS) / Android Vitals alerts to catch regressions after release.
Deliverables:
- Detailed profiling report with before/after metrics
- Annotated code diffs for each optimization
- Configuration recommendations (e.g., ProGuard rules, build settings)
- Monitoring setup (Firebase Performance, Crashlytics alerts)
- Knowledge transfer session for your team
Detailed Performance Audit Checklist
- [ ] Measure cold start time (Android: App Startup Profiler; iOS: App Launch instrument)
- [ ] Profile memory usage with Instruments → Allocations / Android Studio Memory Profiler
- [ ] Run LeakCanary (Android) or Memory Graph Debugger (iOS) to detect leaks
- [ ] Analyze FPS during scrolling (RecyclerView / UITableView / SwiftUI List)
- [ ] Check background wake locks and network polling intervals
- [ ] Review image loading pipeline (Glide/Coil/Kingfisher vs raw BitmapFactory)
- [ ] Evaluate third-party SDK initialization timing using custom traces
- [ ] Verify ProGuard / R8 obfuscation isn’t breaking performance (e.g., reflection)
- [ ] Test on a representative low-end device (e.g., Samsung Galaxy A21, iPhone SE)
Estimated Timeline
| Scope |
Duration |
| Performance audit (existing app) |
3–5 working days |
| Optimizations (tier 1 – low‑hanging fruit) |
1–2 weeks |
| Full optimization campaign (including architecture changes) |
2–8 weeks |
Costs are calculated individually based on app complexity and current codebase state. Contact us for a project estimate and performance review.
Profiling Tools Reference
| Platform |
Tool |
What It Shows |
| iOS |
Xcode Instruments (Time Profiler) |
CPU, call stack, hot methods |
| iOS |
Allocations |
Live objects, memory peaks |
| iOS |
Leaks |
Retention cycles |
| iOS |
MetricKit |
Production metrics (crash rate, hang rate, launch time) |
| Android |
Android Profiler |
CPU, Memory, Network, Energy |
| Android |
Systrace / Perfetto |
System-level traces |
| Android |
LeakCanary |
Memory leaks |
| Android |
Battery Historian |
Energy consumption |
| Flutter |
Flutter DevTools |
Recomposition, frame rendering, memory |
| Flutter |
Dart Observatory |
Dart VM profiling |
MetricKit on iOS is especially valuable: real data from user devices, not simulator. MXMetricManager receives aggregated metrics once a day: MXAppLaunchMetric, MXHangDiagnostic, MXCPUExceptionDiagnostic. Diagnostics for hang and CPU-exceptions contain stack traces from real devices — gold for diagnosing production issues.
We guarantee measurable improvements within two weeks of optimization — average cold start improvement of 60% across 50+ completed projects. Get in touch for a tailored performance review.