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.
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.
--build-cache and --no-cleanWhen 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.
xcodebuild and build-cacheFor 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.
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.
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.
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.
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.
Builds should be deterministic and reproducible. The goal is to reduce silent failures and increase trust in the build pipeline.
--configure-on-demand and --refresh-dependenciesUse --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.
--dry-runUse --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.
When building for both iOS and Android, parallelizing builds reduces total time.
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.
--parallel and --max-workers for Maximum ThroughputIn 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.
In monorepos, where multiple mobile apps and libraries live in a single repository, modular builds are essential.
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.
A build is only as good as the data it produces.
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.
--profile and --debugUse --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.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy