Mobile Performance skill for Claude Code
Faster launches and smooth lists, proven on real devices. Claude treats mobile performance as measurement first: release builds on a mid-range physical device, median and p90 over repeated runs, and a profiler trace before any change. It then applies the fixes that usually matter — deferred SDK init, Baseline Profiles, image downsampling, virtualized lists with stable keys, work moved off the main thread — and adds a benchmark so the gain cannot quietly regress.
- name
mobile-performance - size178 lines · ≈2,150 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Measures and fixes mobile app performance on iOS and Android — cold start time, scroll jank and hitches, list virtualization, image memory and decoding, main-thread work, and memory leaks — using Instruments, MetricKit, Android Studio Profiler, Perfetto, Macrobenchmark, Baseline Profiles, and field vitals, with React Native and Flutter specifics. Use when an app launches slowly, lists stutter, memory warnings or OOM crashes appear, Android vitals flag slow starts, ANRs or frozen frames, or before a release performance check. Try asking
- Our Android app takes three seconds to show the home screen — find out why
- The feed stutters while scrolling on older iPhones
- We get OOM crashes in the photo gallery; fix the memory usage
Use it when
- Launch time is slow or Android vitals flag slow starts
- Lists or animations drop frames
- Memory warnings, jetsam kills or OOM crashes appear
- ANR rate is near or above Play's threshold
Skip it when
- The problem is Compose recomposition specifically — use jetpack-compose
- The slowness is in web pages, not native apps — use core-web-vitals
How to check the result
Before and after numbers come from the same device, build type and procedure with median and p90, the profiler trace shows the targeted work gone, and field vitals improve after release.
The common mistake it prevents
Profiling a debug build on a simulator or flagship phone, which hides real startup and jank costs and produces fixes that do nothing for users.
Performance work is done when there is a before/after measurement on a mid-range physical device in a release build, the change moved a user-facing metric (time to first frame, dropped frames, peak memory, ANR rate), and field data confirms it after release. Debug builds, simulators, and flagship phones are not evidence.
Targets
| Metric | Target | Source |
|---|---|---|
| iOS cold launch to first frame | ≤ 400 ms is Apple’s guidance; investigate anything over 1 s | Instruments App Launch, Xcode Organizer, MetricKit |
| Android cold start (TTID) | Under 1 s on mid-range; Play flags ≥ 5 s cold, ≥ 2 s warm, ≥ 1.5 s hot as excessive | Macrobenchmark, am start -W, Android vitals |
| Frame time | 16.7 ms at 60 Hz, 8.3 ms at 120 Hz | Hitches instrument, gfxinfo, Perfetto |
| Frozen frames | None over 700 ms | Android vitals, Hitches |
| ANR rate (Android) | Below 0.47% user-perceived (Play bad-behavior threshold) | Play Console |
| Peak memory | No OOM/jetsam terminations; image memory bounded | Allocations, Memory Profiler, MetricKit exit reasons |
Workflow
- [ ] 1. Reproduce on a mid-range physical device with a release (or profile) build
- [ ] 2. Record a baseline: median and p90 of 10+ runs
- [ ] 3. Profile to find where time/memory goes (do not guess)
- [ ] 4. Fix the largest contributor first; one change at a time
- [ ] 5. Re-measure with the same procedure
- [ ] 6. Add a regression guard (benchmark test, CI budget)
- [ ] 7. Watch field metrics after release
Measuring startup
iOS:
// XCTest launch benchmark (run on device, release configuration)
func testLaunchPerformance() {
measure(metrics: [XCTApplicationLaunchMetric()]) { XCUIApplication().launch() }
}
- Instruments → App Launch template shows process creation, dylib loading, static
initializers,
didFinishLaunching, and first frame. - Field data: Xcode Organizer → Metrics (launch time, hangs, memory) and MetricKit
(
MXAppLaunchMetric,MXHangDiagnostic) for your own dashboards.
Android:
adb shell am force-stop com.acme.app
adb shell am start -W -n com.acme.app/.MainActivity # TotalTime ≈ time to initial display
adb logcat -d | grep "Displayed com.acme.app" # same metric from the system log
- Call
reportFullyDrawn()when real content is visible to get time to full display. - Macrobenchmark with
StartupTimingMetric(andFrameTimingMetricfor scrolling) gives repeatable numbers with compilation modes; run on a physical device. - Field data: Play Console → Android vitals (startup, ANR, crash, rendering).
Startup fixes (in order of usual payoff)
- Do less before the first frame: lazy-initialize SDKs (analytics, crash, ads, remote
config) after first render or on first use. On Android, use the App Startup library or
lazy holders; on iOS, move work out of
application(_:didFinishLaunchingWithOptions:)and static initializers. - No synchronous network, disk, keychain, or database migrations on the main thread at launch. Show cached content immediately; refresh in the background.
- Android: ship a Baseline Profile (Macrobenchmark +
androidx.baselineprofileplugin) covering startup and core journeys; typically a large win for Compose apps. Enable R8 full mode. - iOS: reduce dynamic frameworks (merge or link statically), remove unused dependencies,
avoid
+load/ heavy static constructors. - Use the platform splash screen API only as a placeholder; never add artificial delays.
- React Native: Hermes (default), avoid large synchronous imports at startup (lazy
requirescreens), keep the initial route light. Flutter: defer plugin initialization, avoid heavy work inmain()beforerunApp.
Lists and scrolling
| Stack | Use | Musts |
|---|---|---|
| UIKit | UICollectionView with diffable data source, prefetching | Reuse cells; size images to cell |
| SwiftUI | List or LazyVStack | Stable Identifiable IDs; light row views (see swiftui-views) |
| Android Views | RecyclerView + ListAdapter/DiffUtil | Stable IDs, no nested same-direction scroll |
| Compose | LazyColumn | key and contentType; stable item models (see jetpack-compose) |
| React Native | FlashList; else FlatList | keyExtractor, memoized renderItem and rows, no inline object props, getItemLayout for fixed heights |
| Flutter | ListView.builder / SliverList.builder | const rows, keys for stateful rows (see flutter-widgets) |
Common jank causes: decoding full-size images on the main thread, heavy row layouts (nested stacks, shadows without paths, blur), synchronous formatting (dates, currency) per row per frame, and loading every item instead of paging.
Images and memory
- Decoded bitmap memory = width × height × 4 bytes. A 12 MP photo decodes to ~48 MB regardless of its JPEG file size. Always decode at display size.
- iOS: downsample with ImageIO (
CGImageSourceCreateThumbnailAtIndexwithkCGImageSourceThumbnailMaxPixelSizeandkCGImageSourceCreateThumbnailFromImageAlways) off the main thread; avoidUIImage(named:)caching for large one-off images. - Android: let Coil or Glide size requests to the view; never
BitmapFactory.decode*full images withoutinSampleSize. - React Native:
expo-imageor a caching image library with explicit dimensions. Flutter:cacheWidth/cacheHeightonImage. - Serve appropriately sized, modern formats (WebP/AVIF/HEIC) from the server or an image CDN.
- Respond to memory pressure (
didReceiveMemoryWarning,onTrimMemory) by clearing caches.
Jank and main-thread work
iOS:
- Instruments → Animation Hitches and Time Profiler (filter to main thread). Look for JSON decoding, Core Data/SwiftData fetches, image decoding, and layout on the main thread.
- Set
shadowPathon layers with shadows; avoid offscreen rendering (masks + corner radius- shadows on scrolling content).
Android:
- Android Studio Profiler (CPU → system trace) or Perfetto (
ui.perfetto.dev) for frame timelines;adb shell dumpsys gfxinfo com.acme.appfor janky frame percentages. - StrictMode in debug builds to catch disk and network access on the main thread.
- ANRs: main thread blocked over 5 s for input. Move I/O to coroutines on
Dispatchers.IO, avoidrunBlockingon the main thread, and check broadcast receivers andSharedPreferences.commit().
React Native:
- Two threads matter: JS and UI. The Perf Monitor shows both frame rates. JS-thread drops come from heavy renders or business logic; move animations to Reanimated worklets (UI thread) and defer non-urgent work.
- Profile React renders in React Native DevTools; fix re-render storms (see
react-rerenders).
Memory leaks
- iOS: Instruments Leaks and the Xcode Memory Graph Debugger. Classic causes: closures
capturing
selfstrongly in stored callbacks, timers, NotificationCenter observers, and delegates not declaredweak. - Android: LeakCanary in debug builds. Classic causes: static references to Activities or
Views, listeners not unregistered, coroutines outliving their scope (use
viewModelScope/lifecycleScope). - Cross-platform: event listeners and subscriptions added without removal on unmount.
- Verify: navigate into and out of a screen 10 times; memory should return to baseline.
Regression guards
- Macrobenchmark startup and scroll tests in CI on a device farm or a dedicated device.
- XCTest performance tests with baselines for launch and key interactions.
- App size budget (download/install size) per release — size affects install conversion and startup.
- Alert on field regressions: launch time, hang rate, ANR rate per release.
Report template
## Performance: cold start (Android)
**Device/build:** Pixel 6a, Android 15, release + R8, baseline profile off → on
**Method:** Macrobenchmark StartupTimingMetric, 15 iterations, CompilationMode.None vs Partial(BaselineProfile)
| Metric | Before (median / p90) | After (median / p90) |
|---|---|---|
| timeToInitialDisplay | 1,480 / 1,690 ms | 910 / 1,040 ms |
**Changes:** Lazy analytics init; moved DB migration off main thread; added baseline profile.
**Guard:** Startup benchmark in nightly CI with 1,100 ms p90 budget.
Verification
- Same device, same build type, same procedure before and after; report median and p90.
- The profiler trace after the fix shows the targeted work gone or moved off the main thread.
- No new regressions in memory or app size.
- After release, field metrics (Organizer, MetricKit, Android vitals) move in the same direction within one or two weeks of adoption.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.