Mobile App Performance Optimisation Techniques

Performance is a feature. Users notice when an app feels sluggish, and they leave. Research consistently shows that even 100 milliseconds of latency affects user perception. A slow app does not just frustrate users; it costs you retention, engagement, and revenue.

This guide covers the optimisation techniques that make the biggest difference in real-world mobile apps. We focus on measurable improvements, not micro-optimisations that look clever in benchmarks but make no difference to users.

Measuring Before Optimising

Measuring Before Optimising Infographic

Never optimise without measuring. Your intuition about what is slow is often wrong. Profile first, then target the actual bottlenecks.

iOS Profiling

Instruments is the primary profiling tool for iOS:

  • Time Profiler: Identifies CPU-intensive code
  • Allocations: Tracks memory usage and leaks
  • Core Animation: Measures rendering performance (FPS)
  • Network: Monitors network request timing and payload sizes

Android Profiling

Android Studio Profiler provides:

  • CPU Profiler: Method tracing and sampling
  • Memory Profiler: Heap allocation tracking and leak detection
  • Network Profiler: Request timeline and payload inspection
  • Energy Profiler: Battery usage analysis

React Native Profiling

  • Flipper: The official debugging tool, includes performance monitoring
  • React DevTools: Component render profiling
  • Systrace: Low-level system trace for diagnosing jank

Flutter Profiling

  • Flutter DevTools: Widget rebuild tracking, timeline view, memory analysis
  • Performance overlay: Real-time FPS display
  • Run in profile mode (flutter run --profile) for accurate measurements

Startup Time Optimisation

App startup time is the first impression. Users expect apps to be interactive within 2 seconds.

Reduce Launch Work

Audit everything that happens during app launch. Common culprits:

  • SDK initialisations: Analytics, crash reporting, and third-party SDKs often initialise synchronously on launch. Defer non-critical SDKs.
  • Database migrations: Run schema migrations asynchronously and show a loading state if needed.
  • Network requests: Do not block the UI on network data. Show cached data or a skeleton screen while fetching.
  • Large asset loading: Defer loading of assets that are not needed for the initial screen.
// iOS: Defer non-critical initialisation
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // Critical: Configure immediately
        FirebaseApp.configure()

        // Deferred: Initialize after first frame
        DispatchQueue.main.async {
            self.initializeAnalytics()
            self.initializeCrashReporting()
            self.prefetchData()
        }

        return true
    }
}

iOS-Specific Startup Optimisations

  • Reduce dylib loading: Each dynamic library adds to launch time. Minimise third-party frameworks or use static linking.
  • Optimise the main storyboard: A complex initial storyboard takes time to inflate. Use a simple launch screen.
  • Pre-warm critical paths: If you know the user will navigate to a specific screen, preload its data during launch.

Android-Specific Startup Optimisations

  • Avoid heavy Application.onCreate(): Keep it minimal. Use lazy initialisation.
  • Use App Startup library: Android Jetpack’s App Startup library enables efficient, ordered initialisation of components.
  • Enable R8/ProGuard: Code shrinking removes unused code, reducing DEX file size and class loading time.
// Android: Lazy SDK initialisation with App Startup
class AnalyticsInitializer : Initializer<AnalyticsService> {
    override fun create(context: Context): AnalyticsService {
        return AnalyticsService.initialize(context)
    }

    override fun dependencies(): List<Class<out Initializer<*>>> {
        return emptyList()
    }
}

Rendering Performa

Rendering Performance Infographic nce

The goal is 60 frames per second (16.67 milliseconds per frame). Dropped frames cause visible jank.

Avoid Unnecessary Rebuilds

The most common rendering performance issue is rebuilding widgets or views that have not changed.

Flutter: Use const constructors, extract widgets into separate classes, and use Selector or context.select() with Provider to rebuild only affected widgets.

// Bad: Entire list rebuilds when any task changes
Consumer<TaskProvider>(
  builder: (context, provider, _) {
    return Column(
      children: [
        Text('Count: ${provider.totalCount}'), // Only needs count
        TaskList(tasks: provider.tasks),        // Needs full list
      ],
    );
  },
)

// Good: Separate consumers for different data
Column(
  children: [
    Selector<TaskProvider, int>(
      selector: (_, provider) => provider.totalCount,
      builder: (_, count, __) => Text('Count: $count'),
    ),
    Consumer<TaskProvider>(
      builder: (_, provider, __) => TaskList(tasks: provider.tasks),
    ),
  ],
)

React Native: Use React.memo() for functional components, implement shouldComponentUpdate for class components, and use useCallback and useMemo to prevent unnecessary re-renders.

// React Native: Optimised list item
const TaskItem = React.memo(({ task, onToggle }) => {
  return (
    <TouchableOpacity onPress={() => onToggle(task.id)}>
      <Text>{task.title}</Text>
    </TouchableOpacity>
  );
});

// Stable callback reference
const TaskList = ({ tasks }) => {
  const handleToggle = useCallback((id) => {
    dispatch(toggleTask(id));
  }, [dispatch]);

  return (
    <FlatList
      data={tasks}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => (
        <TaskItem task={item} onToggle={handleToggle} />
      )}
    />
  );
};

List Performance

Long lists are common performance bottlenecks. Use virtualised lists that render only visible items:

  • iOS: UITableView and UICollectionView (virtualized by default). In SwiftUI, use LazyVStack or List.
  • Android: RecyclerView with ViewHolder pattern. In Compose, use LazyColumn.
  • React Native: FlatList (not ScrollView for long lists).
  • Flutter: ListView.builder (not ListView with all children).

Image Optimisation

Images are often the heaviest assets on screen:

  1. Resize images to display size. Do not load a 4000x3000 pixel image to display at 200x150 points.
  2. Use appropriate formats. WebP offers smaller file sizes than JPEG/PNG with comparable quality. HEIC is excellent on iOS.
  3. Cache aggressively. Use URLCache on iOS, OkHttp cache on Android, or libraries like SDWebImage/Glide.
  4. Lazy load off-screen images. Only load images as they approach the visible area.
// Android: Glide with appropriate sizing
Glide.with(context)
    .load(imageUrl)
    .override(200, 200)     // Load at display size
    .centerCrop()
    .diskCacheStrategy(DiskCacheStrategy.ALL)
    .into(imageView)

Memory Manageme

nt

Memory pressure causes crashes, background termination, and degraded performance.

Identify and Fix Leaks

Memory leaks are insidious. They do not cause immediate crashes but gradually degrade performance until the system kills your app.

Common leak sources:

  • Strong reference cycles (retain cycles) in closures
  • Uncancelled timers and observers
  • Unreleased resources (database connections, file handles)
  • Cached data that grows without bounds
// iOS: Weak self in closures to prevent retain cycles
class TaskDetailViewController: UIViewController {
    var task: Task?

    func loadDetails() {
        // BAD: Strong reference to self
        apiClient.fetchDetails(taskId: task?.id ?? "") { result in
            self.updateUI(with: result) // Retains self
        }

        // GOOD: Weak reference to self
        apiClient.fetchDetails(taskId: task?.id ?? "") { [weak self] result in
            self?.updateUI(with: result) // Does not retain self
        }
    }
}

Manage Cache Size

Caching improves performance but consumes memory. Implement cache eviction:

// React Native: LRU cache with size limit
class LRUCache {
  constructor(maxSize) {
    this.maxSize = maxSize;
    this.cache = new Map();
  }

  get(key) {
    if (this.cache.has(key)) {
      const value = this.cache.get(key);
      // Move to end (most recently used)
      this.cache.delete(key);
      this.cache.set(key, value);
      return value;
    }
    return null;
  }

  set(key, value) {
    if (this.cache.has(key)) {
      this.cache.delete(key);
    } else if (this.cache.size >= this.maxSize) {
      // Remove least recently used (first item)
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }
}

Network Optimisation

Reduce Payload Size

  • Enable gzip/Brotli compression on your API
  • Request only needed fields (sparse fieldsets)
  • Use pagination for list endpoints
  • Compress images before upload

Reduce Request Count

  • Batch related requests where possible
  • Use compound documents to include related resources
  • Cache responses with appropriate HTTP cache headers
  • Pre-fetch data the user is likely to need next

Handle Poor Connectivity

Australian users frequently encounter poor connectivity. Design for it:

  • Show cached data immediately, update when fresh data arrives
  • Implement request timeouts (10 seconds for connection, 30 seconds for response)
  • Use exponential backoff for retries
  • Queue operations when offline and sync when reconnected

App Size Optimisation

Smaller apps download faster, install faster, and are less likely to be deleted when users need storage space.

iOS Size Reduction

  • Enable bitcode: Lets Apple optimise for specific device architectures
  • Use asset catalogs: Automatically provides device-appropriate assets
  • Strip unused code: Enable dead code stripping in build settings
  • Audit dependencies: Remove unused frameworks and libraries
  • Use on-demand resources: Download large assets only when needed

Android Size Reduction

  • Enable R8/ProGuard: Removes unused code and resources
  • Use Android App Bundle: Google Play generates optimised APKs per device configuration
  • Compress PNG assets: Use WebP format where possible
  • Use vector drawables: Replace raster icons with vectors
  • Split APKs by ABI: Separate native libraries by architecture
// Android: Enable bundle and code shrinking
android {
    bundle {
        language { enableSplit = true }
        density { enableSplit = true }
        abi { enableSplit = true }
    }

    buildTypes {
        release {
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
                'proguard-rules.pro'
        }
    }
}

Battery Optimisation

Battery drain is the silent performance killer. Users may not know your app is responsible, but they will notice their phone dying early.

  • Minimise background activity. Use background processing APIs (WorkManager on Android, BGTaskScheduler on iOS) responsibly.
  • Batch network requests. Waking the radio for a single request is expensive. Batch operations together.
  • Reduce location tracking precision. If you only need city-level accuracy, do not request GPS precision.
  • Avoid polling. Use push notifications or WebSockets instead of periodic polling.

Performance Budgets

Set measurable performance targets and track them:

MetricTarget
App startup (cold)Under 2 seconds
Screen transitionUnder 300 milliseconds
Frame rate60fps (under 16.67ms per frame)
Time to interactiveUnder 3 seconds
API response displayUnder 1 second
Memory usage (idle)Under 100MB
App size (download)Under 50MB

Monitor these metrics in CI/CD and flag regressions before they reach production.

Performance optimisation is an ongoing practice, not a one-time task. At eawesome, we build performance monitoring into our development process from day one, ensuring our clients’ apps stay fast as they grow.