Mobile Build Optimization

Production engineering guide for mobile build optimization covering patterns, implementation strategies, and operational best practices.

Mobile build optimization is the practice of reducing build time, memory usage, and failure rate across mobile development workflows—especially in CI/CD pipelines—by aligning configuration, tooling, and dependency management with the structure of the codebase and the frequency of changes. It matters most when teams face slow feedback loops, recurring merge conflicts, and inconsistent release builds across iOS and Android.

Reduce Build Time per Commit

The most immediate benefit of mobile build optimization is faster feedback. A build that takes 15 minutes per commit becomes a bottleneck when developers wait for test results or release approval. The goal is to cut build duration by focusing on incremental builds, caching, and parallelization.

Use Incremental Builds with --build-cache and --no-clean

When building Android apps with Gradle, enable incremental builds using the --build-cache flag:

./gradlew build --build-cache --no-clean

This flag caches task outputs across builds. The --no-clean flag prevents the clean task from running, which avoids re-downloading dependencies and recompiling unchanged code.

To further reduce build time, configure build-cache in gradle.properties:

org.gradle.caching=true
org.gradle.parallel=true
org.gradle.configureondemand=true
org.gradle.jvmargs=-Xmx4g -XX:+UseG1GC

Enabling org.gradle.caching ensures that Gradle stores and reuses task outputs. parallel builds multiple projects simultaneously. configureOnDemand reduces configuration overhead by loading only the projects needed for the current build.

A common failure mode is forgetting to set org.gradle.caching=true. Without it, --build-cache does nothing. The build log shows:

> Task :app:compileDebugKotlin
Skipping task ':app:compileDebugKotlin' as it is up-to-date.

But the task is not up-to-date because the cache is not enabled—resulting in full rebuilds even when source code is unchanged.

Optimize iOS Builds with xcodebuild and build-cache

For iOS, use xcodebuild with build-cache and derivedDataPath:

xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15' \
  -derivedDataPath ./build/derivedData \
  -archivePath ./build/MyApp.xcarchive \
  -configuration Release \
  -derivedDataPath ./build/derivedData \
  -buildCachePath ./build/cache

The derivedDataPath ensures that Xcode stores intermediate build artifacts (like .build, .xcresult, and .xcarchive) in a consistent location. The buildCachePath enables caching of compiled modules, allowing xcodebuild to skip rebuilding unchanged targets.

When buildCachePath is missing, xcodebuild rebuilds everything from scratch even when inputs are unchanged.

Cache Dependencies Between Builds

Use a persistent cache for dependencies. For Android, configure gradle.properties to store cache in the project root:

org.gradle.caches.dir=../.gradle-cache

Then, in CI, restore the cache before each build and upload it after:


if [ -d .gradle-cache ]; then
  cp -r .gradle-cache/. ~/.gradle/
fi

# Run build
./gradlew build --build-cache

# Upload Gradle cache
cp -r ~/.gradle/.gradle-cache .

For iOS, cache Pods/, Carthage/, and build/derivedData directories.

The Podfile.lock file is critical: changing it triggers a full pod install, but it is often ignored in caching. When Podfile.lock is not updated, pod install runs even when Podfile is unchanged—causing unnecessary rebuilds.

Reduce Memory Usage in CI

High memory usage leads to out-of-memory (OOM) crashes, especially on CI runners with limited RAM. The goal is to keep builds stable across different environments.

Configure JVM Memory for Gradle and Xcode

For Gradle, set org.gradle.jvmargs to allocate sufficient memory:

org.gradle.jvmargs=-Xmx6g -Xms2g -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError

This increases heap size to 6 GB and enables garbage collection tuned for large heaps. The HeapDumpOnOutOfMemoryError flag produces a heapdump.hprof file when OOM occurs, which can be analyzed later.

For Xcode, set XCODEBUILD_JVM_ARGS in CI:

export XCODEBUILD_JVM_ARGS="-Xmx4g -Xms1g -XX:+UseG1GC"

Without this, Xcode often runs out of memory when compiling large iOS apps with many targets.

Use Build Profiles for Different Environments

Define build profiles in build.gradle to adjust memory and caching per environment.

// In build.gradle
if (project.hasProperty('ci')) {
  // Apply CI-specific optimizations
  apply plugin: 'build-cache'

  tasks.withType(JavaCompile) {
    options.fork = true
    options.memoryMaximumSize = '4g'
    options.compilerArgs << '-J-Xmx2g'
  }

  tasks.withType(Test) {
    maxParallelForks = 4
    forkEvery = 10
  }
}

When running in CI, pass ci as a project property:

./gradlew build -Pci

This enables the ci profile and applies optimized settings.

A common mistake is setting fork = true but forgetting to set memoryMaximumSize. Without it, JavaCompile tasks use default memory, leading to OOM on large projects.

Minimize Build Failures and Improve Reliability

Builds should be deterministic and reproducible. The goal is to reduce silent failures and increase trust in the build pipeline.

Ensure Deterministic Builds with --configure-on-demand and --refresh-dependencies

Use --configure-on-demand in Gradle to reduce configuration time:

./gradlew build --configure-on-demand --refresh-dependencies

This flag ensures that only the projects needed for the build are configured. Without it, Gradle configures every subproject—even when only one is needed—leading to longer build times and inconsistent configurations.

To make builds truly deterministic, include --refresh-dependencies to force Gradle to update all dependency versions:

./gradlew build --refresh-dependencies --build-cache --no-clean

This ensures that the build uses the latest versions of all dependencies, even when the build.gradle file is unchanged.

A silent failure occurs when --refresh-dependencies is used but --build-cache is not. The build downloads updated dependencies but does not reuse compiled output, so the build is slow and memory-intensive.

Validate Build Configuration with --dry-run

Use --dry-run to validate the build configuration before running it in CI:

./gradlew build --dry-run --no-daemon

This flag shows what tasks will be executed without running them. It is useful for debugging, documenting, and testing new configurations.

The output shows a tree of tasks with their dependencies and up-to-date status. A common issue is missing --dry-run in CI setup, leading to unexplained build times and unexpected task execution.

Parallelize Builds Across Platforms

When building for both iOS and Android, parallelizing builds reduces total time.

Run iOS and Android Builds in Parallel

Use make or shell scripts to run both builds in parallel:

#!/bin/bash
# build-all.sh

# Run Android build in background
(
  cd android
  ./gradlew build --build-cache --no-clean
) &

# Run iOS build in background
(
  cd ios
  xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15' \
    -derivedDataPath ./build/derivedData \
    -archivePath ./build/MyApp.xcarchive \
    -configuration Release \
    -buildCachePath ./build/cache
) &

# Wait for both to complete
wait

The wait command ensures that the script waits for both background processes to finish.

A common failure is forgetting wait—resulting in the script exiting before iOS or Android builds complete. This leads to missing artifacts and incomplete CI reports.

Use --parallel and --max-workers for Maximum Throughput

In Gradle, use --parallel to run multiple projects in parallel:

./gradlew build --parallel --max-workers=4 --build-cache --no-clean

This enables up to 4 worker threads to execute tasks concurrently. The --max-workers flag controls the number of threads.

When --parallel is used without --max-workers, Gradle defaults to the number of available processors—often leading to oversubscription on CI runners with few CPUs.

Handle Large Monorepos with Modular Builds

In monorepos, where multiple mobile apps and libraries live in a single repository, modular builds are essential.

Define Modular Build Tasks

Create custom Gradle tasks that build only the modules affected by a change:

// In root build.gradle
task buildModules(type: Task) {
  description 'Build only modules affected by changes'
  group 'build'

  doLast {
    def changedModules = []
    // Use git to find changed modules
    def output = 'git diff --name-only HEAD~1 HEAD'.execute().text
    output.split('\n').each { file ->
      if (file.startsWith('modules/')) {
        def module = file.split('/')[1]
        changedModules << module
      }
    }

    def tasks = changedModules.collect { "modules/$it:build" }
    exec {
      executable './gradlew'
      args tasks
      args '--build-cache'
      args '--no-clean'
    }
  }
}

This task uses git diff to identify changed modules and builds only those. It can be triggered in CI:

./gradlew buildModules -Pci

A failure mode is not including --build-cache in the exec block. Without it, each module rebuilds from scratch.

Monitor and Debug Builds

A build is only as good as the data it produces.

Capture Build Logs and Artifacts

Always capture build logs and artifacts:

./gradlew build --build-cache --no-clean --stacktrace --info --scan

xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -destination 'platform=iOS Simulator,name=iPhone 15' \
  -derivedDataPath ./build/derivedData \
  -archivePath ./build/MyApp.xcarchive \
  -configuration Release \
  -buildCachePath ./build/cache \
  -showBuildTimingSummary

The --stacktrace flag includes full Java stack traces for all failures. --scan uploads build reports to Gradle Enterprise. showBuildTimingSummary provides a breakdown of time spent in each phase.

A common oversight is missing --stacktrace. When a build fails, only a short error message appears, but the full stack trace is buried in the log.

Debug Build Failures with --profile and --debug

Use --profile to generate a detailed profile of the build:

./gradlew build --profile --debug --build-cache

This generates build/profile-report.html and build/profile.json, which show task execution times, memory usage, and dependency resolution.

The --debug flag enables verbose logging, showing which tasks are executed and why. It is invaluable for diagnosing why a task is not up-to-date.

When --profile is used without --debug, the profile lacks context—making it hard to correlate build events with logs.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.