Mobile App Optimization: Cold Start, Memory, Battery, FPS, Profiling
An app with a cold start time of 4+ seconds loses users before the first screen. Android Vitals in Google Play Console directly affects search ranking: apps with poor metrics get lower organic reach. Apple similarly monitors crash rate and launch time via MetricKit. Optimization is not "making it faster," but understanding where exactly time is lost and what to do about it.
Cold Start: Where Time is Lost Before First Frame
Cold start is launching an app when the process doesn't exist 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 at Initialization
Application.onCreate()—the main enemy of fast startup on Android. Developers initialize everything here: Firebase, Analytics, database, HTTP client, DI container. Each SDK adds 20–200 ms to the main thread.
Diagnostic tool: Android Studio Profiler → App Startup. Shows an 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 initialized lazily—by lazy {} or initializer with lazyInit flag. Firebase Analytics, for example, isn't needed until the first user action—its initialization can be deferred.
ContentProviders automatically added by SDK via AndroidManifest merge also start at app launch. tools:node="remove" in the manifest lets you disable a specific provider and initialize the SDK manually when needed.
Another trap: Room.databaseBuilder().build() on the main thread. This is a synchronous operation to create/open the database file—on slow devices, it takes 50–300 ms. Move to a coroutine with Dispatchers.IO, in a 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 is the time to load dylib, rebase/binding, Objective-C runtime initialization, and execution of +load methods.
Xcode Instruments → App Launch template shows pre-main and post-main times separately. DYLD_PRINT_STATISTICS=1 in the launch scheme outputs detailed timing to the console.
What kills pre-main:
- Many dynamic libraries (each dylib—linking overhead). CocoaPods adds a separate dylib per pod. Solution: Swift Package Manager with static linking (
type: .static) oruse_frameworks! :linkage => :staticin CocoaPods. -
+loadmethods in Objective-C—execute synchronously when the class loads, beforemain(). Third-party SDKs may abuse this.+initialize—a lazy analog, called on first access to the class.
Post-main is application(_:didFinishLaunchingWithOptions:). Same story as Android: synchronous initialization of everything. lazy var for services not needed immediately. SwiftUI @StateObject initializes the object only when the View appears—this is already built-in laziness.
Target metrics (App Store recommendations): cold start < 400 ms for simple apps, < 2 seconds for complex. Warm start (process in memory, but Activity/Scene is recreated) < 1 second.
Memory: Leaks, OOM, Excessive Pressure
A memory leak in iOS is a retention cycle: object A holds a reference to B, B holds to A, neither is freed. Classic: Timer with self in a closure without [weak self]. Timer holds the closure, the closure holds self (ViewController), ViewController is not freed when closed. Instruments → Leaks or Memory Graph Debugger in Xcode—finds live objects that shouldn't exist.
On Android, garbage collector manages memory, but leaks still happen. Activity or Fragment held via static reference, singleton, or Handler/Runnable after onDestroy—classic. LeakCanary is a must-have in debug builds. Added with one dependency debugImplementation "com.squareup.leakcanary:leakcanary-android" and automatically detects leaks with full stack trace.
OutOfMemoryError usually happens from loading images. Bitmap in memory takes width × height × 4 bytes. A 4000×3000 px image—48 MB in memory, regardless of file size on disk. Glide / Coil handle this correctly: load with downsampling to View size, cache in LRU cache. Loading to ImageView without Glide/Coil via BitmapFactory.decodeFile—path to OOM on 2 GB RAM devices.
On Flutter, 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 on exit from the widget tree—on long lists with images, use cached_network_image with proper memCacheWidth/memCacheHeight.
FPS and UI Performance
60 FPS—16.67 ms per frame. 120 FPS (ProMotion)—8.33 ms. Anything more on the main thread—jank.
Typical FPS drops causes:
On iOS: synchronous image decoding in cellForRowAt. When a table cell appears, UIImage(contentsOfFile:) decodes JPEG/PNG on the main thread—visible as sluggish scroll on long lists. Solution: UIImage.preparingForDisplay() (iOS 15+) or ImageIO with kCGImageSourceCreateThumbnailWithTransform in 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 shows all violations.
On Flutter: build() method is called often, it should be cheap. setState() on a top widget rebuilds the whole tree. const constructors, RepaintBoundary, breaking into small widgets with local state—main tools. Flutter DevTools → Performance shows janky frames (red) with causes.
Profiling Compose: Recomposition Highlighter and tracing via Trace.beginSection in @Composable. remember for expensive computations, derivedStateOf for computed values, LazyColumn instead of Column + forEach for long lists.
Battery: Wake Locks, WorkManager, Network Requests
An app at the top of battery usage—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:
- Constant GPS (see maps-geo)
- Polling network every N seconds instead of push
- Holding wake lock longer than needed
- Excessive
AlarmManagerwakeups
WorkManager with Constraints—the right way to schedule background tasks: setRequiredNetworkType, setRequiresBatteryNotLow, setRequiresCharging. The OS batches tasks and runs at convenient times.
On iOS, BGTaskScheduler with BGProcessingTaskRequest (for heavy tasks on charging) and BGAppRefreshTaskRequest (for light updates)—the system decides when to run, the developer just registers and implements logic.
Network request batching: instead of 10 separate requests over a minute—one batch request. Fewer radio activations (LTE radio consumes a lot on connection initialization), fewer wakeups.
Profiling Tools
| 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 the emulator. MXMetricManager gets aggregated metrics once a day: MXAppLaunchMetric, MXHangDiagnostic, MXCPUExceptionDiagnostic. Diagnostics for hang and CPU-exceptions contain a stack trace from a real device—gold for diagnosing production issues.
Optimization Process
Start with measurement, not assumptions. The tools above give numbers: specific cold start time, specific memory volume, specific frames with drops. Then—prioritize by impact: what affects user experience most in this specific app.
Auditing existing app performance: 3–5 business days. Implementing optimizations—from a week to two months depending on problem severity and code architecture.







