Complete guide to identifying and fixing performance issues in native apps
- Profiling Tools Overview
- iOS Profiling with Instruments
- Android Profiling
- Performance Metrics
- Common Performance Issues
- Optimization Strategies
| Platform | Primary Tool | Purpose |
|---|---|---|
| iOS | Xcode Instruments | CPU, Memory, GPU, Energy |
| Android | Android Studio Profiler | CPU, Memory, Network, Energy |
| Android | Perfetto | System-wide traces |
| All | Custom FPS counter | Real-time frame rate |
| Refresh Rate | Frame Time Budget | Use Case |
|---|---|---|
| 60 Hz | 16.67ms | Standard displays |
| 90 Hz | 11.11ms | High-refresh Android |
| 120 Hz | 8.33ms | ProMotion iOS, flagship Android |
Critical Rule: If any single frame takes longer than the budget, you'll see a dropped frame (jank).
# From command line
instruments -t "Time Profiler" -D trace.trace MyApp.app
# Or from Xcode: Product → Profile (⌘I)Use for: Finding CPU bottlenecks
How to read:
- Heaviest Stack Trace shows methods consuming most CPU time
- Call Tree shows hierarchical view
- Look for methods >16ms in main thread
Example problematic trace:
Main Thread (92% CPU)
├─ UIView.layoutSubviews: 18ms ❌ Too slow!
│ └─ CustomView.calculateLayout: 15ms
│ └─ Heavy computation in render loop
Fix:
// BAD - Computation in render
override func layoutSubviews() {
super.layoutSubviews()
let result = expensiveCalculation() // ❌ 15ms
label.text = result
}
// GOOD - Cache result
private var cachedResult: String?
override func layoutSubviews() {
super.layoutSubviews()
label.text = cachedResult ?? ""
}
func updateContent() {
DispatchQueue.global().async {
let result = self.expensiveCalculation()
DispatchQueue.main.async {
self.cachedResult = result
self.setNeedsLayout()
}
}
}Use for: Finding memory leaks
Steps:
- Record baseline memory usage
- Perform action (open/close screen)
- Return to initial state
- Check if memory returned to baseline
Red flags:
- Memory keeps growing with each iteration
- Objects not deallocated after use
- Retain cycles keeping objects alive
Example leak:
// BAD - Retain cycle
class ViewController: UIViewController {
var completion: (() -> Void)?
func loadData() {
dataService.fetch { [self] data in
self.updateUI(data) // ❌ Strong reference to self
self.completion?()
}
}
}
// GOOD - Weak reference
func loadData() {
dataService.fetch { [weak self] data in
guard let self else { return }
self.updateUI(data)
self.completion?()
}
}Use for: Finding rendering bottlenecks
Key metrics:
- FPS graph (should be flat at 60)
- Color-coded rendering issues:
- Red: Offscreen rendering
- Yellow: Rasterized layers
- Green: GPU-accelerated
Common issues:
- Offscreen rendering (RED):
// BAD - Forces offscreen rendering
layer.cornerRadius = 10
layer.masksToBounds = true
layer.shadowOpacity = 0.5 // ❌ Shadow + mask = expensive!
// GOOD - Use shadow path
layer.cornerRadius = 10
layer.shadowOpacity = 0.5
layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath- Blending (expensive pixel compositing):
// BAD - Transparent background
view.backgroundColor = .clear // ❌ Requires blending
// GOOD - Opaque background
view.backgroundColor = .white
view.isOpaque = true- View hierarchy too deep:
UIViewController
└─ UIView (10 nested levels) ❌ Expensive!
└─ UIView
└─ UIView
└─ ...
Fix: Flatten hierarchy, use UIStackView or SwiftUI
Use for: Detecting memory leaks automatically
How it works: Instruments analyzes retain counts and flags objects that should be deallocated but aren't.
Common leak patterns:
- Closure capture:
// Leak
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.update() // ❌ Captures self strongly
}
// Fixed
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.update()
}- Delegate retain cycle:
// Protocol should be class-bound and weak
protocol MyDelegate: AnyObject {
func didUpdate()
}
class MyClass {
weak var delegate: MyDelegate? // ✅ Weak reference
}Look for:
- Main thread CPU usage >80% sustained
- Methods taking >5ms in render loop
- Synchronous operations on main thread
Example report:
Main Thread: 1,234 samples (75% of total)
├─ URLSession.dataTask: 456 samples (37%) ❌ Network on main thread!
├─ JSONDecoder.decode: 234 samples (19%) ❌ Heavy parsing
└─ UIView.layoutSubviews: 123 samples (10%)
Background Thread: 321 samples (25% of total)
└─ Image processing: 321 samples ✅ Good!
Fix strategy:
- Move URLSession to background queue
- Use background queue for JSON parsing
- Cache decoded results
Steps:
- Run app with profiling enabled
- Click "Record" in CPU Profiler
- Perform actions you want to profile
- Click "Stop" and analyze
Viewing options:
- Flame Chart: Visualize call stacks over time
- Top Down: Shows methods sorted by CPU usage
- Bottom Up: Shows callees from method perspective
- Call Chart: Timeline view of method execution
Example flame chart analysis:
MainActivity.onCreate (800ms)
├─ RecyclerView.setAdapter (600ms) ❌ Slow!
│ └─ Adapter.onCreateViewHolder (580ms)
│ └─ LayoutInflater.inflate (570ms) ❌ Complex layouts
└─ Database query (150ms)
Fix:
// BAD - Complex layout inflated on main thread
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.complex_item, parent, false) // ❌ 570ms!
return ViewHolder(view)
}
// GOOD - Simplified layout + ViewHolder pattern
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
// Use ConstraintLayout to flatten hierarchy
// Use view binding for faster inflation
return ItemViewHolder(ItemBinding.inflate(
LayoutInflater.from(parent.context), parent, false
))
}Features:
- Real-time memory graph
- Heap dump analysis
- Allocation tracking
- Garbage collection events
Detecting leaks:
- Baseline capture:
- Open screen → Capture heap dump
- Action:
- Navigate away → Force GC
- Verification:
- Capture heap dump → Compare
Example leak:
Instance View:
- MainActivity (1 instance) ❌ Should be 0 after back press
- Retained by: lambda$onCreate$0
- Retained by: AnimatorSet
- Retained by: Global AnimatorPool
Fix:
// BAD - Animation holds reference to Activity
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
button.setOnClickListener {
animateButton() // ❌ Implicit reference to Activity
}
}
private fun animateButton() {
ObjectAnimator.ofFloat(button, "alpha", 0f, 1f).apply {
duration = 1000
start() // ❌ Animator may outlive Activity
}
}
}
// GOOD - Cancel animations on destroy
class MainActivity : AppCompatActivity() {
private val animators = mutableListOf<Animator>()
private fun animateButton() {
ObjectAnimator.ofFloat(button, "alpha", 0f, 1f).apply {
duration = 1000
animators.add(this)
start()
}
}
override fun onDestroy() {
super.onDestroy()
animators.forEach { it.cancel() }
animators.clear()
}
}Enable: Developer Options → Profile GPU Rendering → "On screen as bars"
Reading the graph:
- Each vertical bar = one frame
- Green horizontal line = 16ms (60fps target)
- Colors show different phases:
- Blue: Drawing commands
- Orange: CPU work
- Red: GPU work
If bars exceed green line:
- Simplify layouts (reduce hierarchy depth)
- Reduce overdraw (avoid drawing same pixels multiple times)
- Optimize custom drawing (Canvas operations)
Overdraw detection:
Settings → Developer Options → Debug GPU Overdraw → Show overdraw areas
Colors mean:
- No color: 1x draw (ideal)
- Blue: 2x draw (acceptable)
- Green: 3x draw (concerning)
- Pink: 4x draw (problem)
- Red: 5x+ draw (critical issue)
Fix overdraw:
// BAD - Multiple background layers
<LinearLayout android:background="@color/white"> ❌
<CardView android:background="@color/white"> ❌
<TextView android:background="@color/white" /> ❌
</CardView>
</LinearLayout>
// GOOD - Remove redundant backgrounds
<LinearLayout>
<CardView>
<TextView android:background="@color/white" />
</CardView>
</LinearLayout>Use for: Understanding system-level performance issues
Capture trace:
# Record 10-second trace
adb shell perfetto \
-c - --txt \
-o /data/misc/perfetto-traces/trace \
<<EOF
buffers: {
size_kb: 63488
}
data_sources: {
config {
name: "linux.ftrace"
ftrace_config {
ftrace_events: "sched/sched_switch"
ftrace_events: "power/cpu_frequency"
ftrace_events: "power/cpu_idle"
}
}
}
duration_ms: 10000
EOF
# Pull trace
adb pull /data/misc/perfetto-traces/trace .Open in: https://ui.perfetto.dev
What to look for:
- Main thread blocked by other processes
- CPU frequency throttling
- Thread priority inversions
- I/O wait times
iOS:
// Measure via Instruments or Xcode Organizer
// Target: <1s cold start
// Log startup time
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let startupTime = ProcessInfo.processInfo.systemUptime
print("App started in \(startupTime)s")
return true
}Android:
// Measure via Logcat
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
val startTime = SystemClock.elapsedRealtime()
// App initialization
val endTime = SystemClock.elapsedRealtime()
Log.d("Startup", "Time: ${endTime - startTime}ms")
}
}
// Or use App Startup libraryOptimization strategies:
- Lazy initialize non-critical components
- Use baseline profiles (Android)
- Defer heavy SDK initialization
- Load initial data asynchronously
Target: Consistent 60 FPS (or 120 FPS on high-refresh displays)
Measuring:
iOS:
// Use CADisplayLink for accurate FPS measurement
class FPSCounter {
private var displayLink: CADisplayLink?
private var lastTimestamp: CFTimeInterval = 0
private var frameCount: Int = 0
func start() {
displayLink = CADisplayLink(target: self, selector: #selector(tick))
displayLink?.add(to: .main, forMode: .common)
}
@objc private func tick(link: CADisplayLink) {
if lastTimestamp == 0 {
lastTimestamp = link.timestamp
}
frameCount += 1
let elapsed = link.timestamp - lastTimestamp
if elapsed >= 1.0 {
let fps = Double(frameCount) / elapsed
print("FPS: \(fps)")
lastTimestamp = link.timestamp
frameCount = 0
}
}
}Android:
class FPSMonitor(private val view: View) {
private var frameCount = 0
private var lastTime = System.nanoTime()
fun start() {
view.choreographer.postFrameCallback(object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
frameCount++
val elapsed = (frameTimeNanos - lastTime) / 1_000_000_000.0
if (elapsed >= 1.0) {
val fps = frameCount / elapsed
Log.d("FPS", "FPS: $fps")
lastTime = frameTimeNanos
frameCount = 0
}
view.choreographer.postFrameCallback(this)
}
})
}
}Healthy ranges:
- Small app: 50-100 MB
- Medium app: 100-200 MB
- Large app: 200-500 MB
-
500 MB: Investigate leaks
Monitor:
// iOS
let memoryUsage = reportMemory()
print("Memory: \(memoryUsage / 1024 / 1024) MB")
func reportMemory() -> UInt64 {
var info = mach_task_basic_info()
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size)/4
let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: 1) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
}
}
return info.resident_size
}// Android
val runtime = Runtime.getRuntime()
val usedMemory = (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024
Log.d("Memory", "Used: ${usedMemory} MB")Symptom: UI freezes during operations
Cause: Network, database, or heavy computation on main thread
Fix:
// BAD
func loadData() {
let data = try! Data(contentsOf: url) // ❌ Blocks main thread
updateUI(data)
}
// GOOD
func loadData() async {
do {
let data = try await URLSession.shared.data(from: url).0
await MainActor.run {
updateUI(data)
}
} catch {
// Handle error
}
}Symptom: High GPU usage, battery drain
Cause: Animating non-compositor properties or triggering unnecessary layouts
Fix:
// BAD - Animates frame (triggers layout)
UIView.animate(withDuration: 0.3) {
view.frame.size.width = 200 // ❌ Layout recalculation
}
// GOOD - Animates transform (compositor)
UIView.animate(withDuration: 0.3) {
view.transform = CGAffineTransform(scaleX: 2.0, y: 1.0)
}Common patterns:
// 1. Strong reference cycles
class ViewController {
var closure: (() -> Void)?
func setup() {
closure = {
self.doSomething() // ❌ Captures self
}
}
}
// Fix
closure = { [weak self] in
self?.doSomething()
}
// 2. Timer not invalidated
class TimerViewController {
var timer: Timer?
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
self.update() // ❌ Retains self
}
}
}
// Fix
func start() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.update()
}
}
deinit {
timer?.invalidate()
}Symptom: Scrolling jank in lists
Cause: Complex cell layouts, expensive cell preparation
Fix:
// BAD - Expensive work in onBindViewHolder
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
holder.image.setImageBitmap(
BitmapFactory.decodeFile(item.imagePath) // ❌ I/O on main thread
)
}
// GOOD - Use image loading library
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = items[position]
Glide.with(holder.itemView.context)
.load(item.imagePath)
.into(holder.image) // ✅ Async loading with caching
}- Use
shouldRasterizefor static complex views - Avoid
cornerRadius+masksToBoundstogether - Set
shadowPathexplicitly when using shadows - Mark views as
opaquewhen possible - Use
UIStackViewto reduce view hierarchy - Profile with Instruments before optimizing
- Test on oldest supported device
- Use
@MainActorfor UI updates - Implement proper deinitialization
- Implement Baseline Profiles
- Use
ConstraintLayoutto flatten hierarchy - Enable R8 full mode for release builds
- Use
RecyclerViewwith proper ViewHolder recycling - Implement
DiffUtilfor efficient list updates - Use
Flowinstead ofLiveDatafor better performance - Profile with Android Studio Profiler
- Test on low-end devices (1-2GB RAM)
- Use Jetpack Compose with strong skipping mode
- Use Reanimated 3+ for animations
- Enable New Architecture (Fabric + TurboModules)
- Use
FlatListwith proper memoization - Implement native modules for heavy operations
- Use
useMemoanduseCallbackappropriately - Enable Hermes engine
- Profile with Flipper
- Test on mid-range Android devices
Create a performance budget for your app:
Metric | Target | Maximum | Current
------------------------|---------|---------|--------
Cold start time | 1.5s | 2.5s | _____
Frame rate (avg) | 58 FPS | 55 FPS | _____
Frame rate (p95) | 60 FPS | 57 FPS | _____
Memory (idle) | 80 MB | 150 MB | _____
Memory (peak) | 200 MB | 350 MB | _____
Battery (1hr active) | 8% | 12% | _____
App size (iOS) | 30 MB | 50 MB | _____
App size (Android) | 25 MB | 40 MB | _____
Network usage (session) | 5 MB | 10 MB | _____
Monitor these metrics in CI/CD and alert on regressions.
Performance optimization is an ongoing process:
- Measure with profiling tools
- Identify bottlenecks using data
- Optimize the slowest parts first
- Verify improvements with profiling
- Monitor in production with analytics
Remember: Don't optimize prematurely. Profile first, then optimize based on real data.