Your iOS app's size under control. Caliper analyzes bundle sizes down to the module level, tracks team ownership, and generates beautiful interactive reports. Know exactly what's taking up space, who owns it, and where to optimize—all from a single command.
Module Size Breakdown |
Size Insights |
Module Ownership |
# Build
swift build -c release
# Analyze IPA with all features
.build/release/caliper \
--ipa-path MyApp.ipa \
--link-map-path MyApp-LinkMap.txt \
--ownership-file module-ownership.yml \
--package-resolved-path Package.resolvedGenerates report.json and report.html in the current directory.
- Binary Size Analysis - Accurate per-module binary sizes from LinkMap files
- Asset Tracking - Detailed breakdown of images, storyboards, and resources
- Module Ownership - Track which team owns which modules
- Version Tracking - Swift package version information from Package.resolved
- Interactive Reports - Searchable HTML reports with filtering and sorting
- Size Metrics - Both compressed (IPA) and uncompressed (installed) sizes
- Automatic App Detection - Identifies and tags main app module automatically
git clone https://github.com/kibotu/caliper.git
cd caliper
swift build -c release
# Binary will be at: .build/release/calipermake install
# Installs to /usr/local/bin/caliper# Install
mint install kibotu/caliper
# Run
mint run kibotu/caliper --ipa-path MyApp.ipa
# Or install globally
mint install kibotu/caliper@main
caliper --ipa-path MyApp.ipaMint builds a real Apple bundle, so it keeps the resource bundle beside the executable in a
caliper_CaliperCore.bundledirectory and both reports are written. Releases downloaded by hand ship the same bundle as a separate archive instead, which does have to be extracted — see Downloading a release.
Each release has two assets: caliper and caliper_CaliperCore.bundle.tar.gz. The bundle
holds the HTML template and the vendored d3, so the two must sit in the same directory —
that is where the binary looks for it.
# Download both assets into one directory, then:
tar -xzf caliper_CaliperCore.bundle.tar.gz
chmod +x caliper
./caliper --ipa-path MyApp.ipaWithout the bundle, the JSON report is still written and the HTML report cannot be produced.
Minimum required input - analyzes bundle structure and resources:
.build/release/caliper --ipa-path MyApp.ipaAdd LinkMap for accurate per-module binary sizes:
.build/release/caliper \
--ipa-path MyApp.ipa \
--link-map-path MyApp-LinkMap.txtHow to generate LinkMap:
- Xcode → Build Settings
- Search for "Write Link Map File"
- Set to
YES - Build your app
- Find LinkMap at:
~/Library/Developer/Xcode/DerivedData/YourApp-xxx/Build/Intermediates.noindex/YourApp.build/Release-iphoneos/YourApp.build/YourApp-LinkMap-normal-arm64.txt
Track which team owns which modules:
.build/release/caliper \
--ipa-path MyApp.ipa \
--link-map-path MyApp-LinkMap.txt \
--ownership-file module-ownership.ymlmodule-ownership.yml example:
# Pattern matching with wildcards
- identifier: "*CoreFeature*"
owner: Core Team
internal: true
- identifier: "MyApp"
owner: App Team
internal: true
- identifier: "ThirdParty*"
owner: ExternalMultiple owners:
A module can belong to more than one team. Use owners for a list; the first entry is the
primary owner and the rest are co-owners:
- identifier: "SharedUI"
owners:
- ui-team
- design-systemsEvery owner is badged on the module card, co-owners in a lighter gradient, and each badge
filters the Breakdown tab to that team. The All teams dropdown beside the search box is
the same filter: it offers every team named anywhere in the report, including teams that
only ever co-own something, and filtering matches on any owner rather than the primary
alone — so a shared module stays visible to each team that owns it.
The Ownership tab counts shared modules twice, on purpose. Its chart groups by primary owner, so the teams partition the app and the bars add up to it. The per-team list below counts a shared module in full for every team that owns it, so those totals add up to more than the app. There is no honest way to split a framework's bytes between two teams, so the list over-counts rather than inventing a share. Compare the chart against the app total, and read the list as "what this team is responsible for".
Note: The main app module is automatically tagged with owner: "App" and internal: true
even without an ownership file. An ownership file that writes owners: [app, ...] therefore
produces two teams differing only in case, and both appear in the team filter. Write App
in the file if you want it to be the same team.
Pattern syntax:
*= any characters?= single characterinternal: true= marks as first-party codeowner= team/group name for reportingowners= list of teams for co-owned modules
Everything that is not a wildcard is matched literally, so Foundation.tbd matches only
itself. The first matching entry wins, so put specific patterns above general ones.
Include Swift package version information:
.build/release/caliper \
--ipa-path MyApp.ipa \
--link-map-path MyApp-LinkMap.txt \
--package-resolved-path Package.resolvedWhere to find Package.resolved:
- Xcode projects:
YourProject.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved - SPM projects:
Package.resolvedin project root
For namespaced packages (e.g., internal packages):
.build/release/caliper \
--ipa-path MyApp.ipa \
--link-map-path MyApp-LinkMap.txt \
--package-resolved-path Package.resolved \
--package-mapping-file package-name-mapping.ymlpackage-name-mapping.yml example:
- moduleName: AdjustSDK
packageIdentity: com.company.adjust-sdk
- moduleName: InternalCore
packageIdentity: internal.core-framework| Parameter | Required | Description |
|---|---|---|
--ipa-path |
✅ Yes | Path to the IPA file to analyze |
--link-map-path |
⬜ No | Path to LinkMap file for accurate binary sizes |
--ownership-file |
⬜ No | YAML file with module ownership patterns |
--package-resolved-path |
⬜ No | Path to Package.resolved for version tracking |
--package-mapping-file |
⬜ No | YAML file for namespaced package mappings |
--output-dir |
⬜ No | Directory to write the reports into (default: current directory) |
--max-package-size |
⬜ No | Fail if the IPA exceeds this many bytes |
--max-install-size |
⬜ No | Fail if the installed size exceeds this many bytes |
Pass a limit and Caliper exits non-zero when the build exceeds it, which makes it usable as a CI gate:
caliper --ipa-path MyApp.ipa \
--max-package-size $((100 * 1024 * 1024)) \
--max-install-size $((150 * 1024 * 1024))Both reports are written before the check runs, so a failed build still leaves you the evidence behind it.
| Data Type | Source | Compression | Notes |
|---|---|---|---|
| Module Names | IPA structure (.framework, .bundle) | - | Extracted from bundle hierarchy |
| Binary Sizes | LinkMap file | Uncompressed | Compiled code size per module |
| Asset Sizes | IPA + .car files | Both | Images: compressed (IPA) + uncompressed (installed) |
| Resource Files | IPA archive | Compressed | .plist, .strings, .nib, .storyboardc, etc. |
| Package Versions | Package.resolved | - | Swift package dependency versions |
| Total IPA Size | IPA file | Compressed | Download/App Store size |
| Total Install Size | Unzipped IPA | Uncompressed | Actual installed app size |
| Asset Catalog Details | .car files via assetutil | Uncompressed | Per-asset breakdown of the expanded catalog, recorded as image detail (installed size). The download figure comes from the .car's own compressed size in the IPA listing |
An Assets.car is a single file in the IPA, so it counts towards the download size at
exactly the compressed size unzip -v reports for it. assetutil is run as well, but
what it reports is each rendition's size after the catalog has been expanded — an
installed-size figure. Those per-asset numbers are recorded separately, in
assetCatalogFiles, and never added to any total, because treating them as compressed
bytes would overstate the download by the catalog's compression ratio.
A catalog is a bundle, and a bundle tells you nothing about what it costs, so the report
lists the assets inside it rather than listing the .car itself. That is what the
Largest Resources chart on the Insights tab shows: every file in the bundle that is
not compiled code — .strop, .json, .strings, images, and each unpacked catalog
asset — with the .car omitted so the container does not appear alongside its own
contents. The chart omits it on both sizes. A compressed reading could justify a
container row, but a price is not something anyone can act on, and the .car's
compressed size is on the module panel either way.
In the module panel these are two sections rather than one list, because they are two
different kinds of thing: Resources holds the files that are in the bundle, and
Asset Catalog holds the renditions unpacked from the catalog, headed by the .car's
compressed size. That size is the one that reaches the download total; the renditions
beneath it are the same bytes expanded, so it is a note about their unit rather than a
figure to add to them.
The per-asset sum is a breakdown, not a total: it omits catalog overhead, so it will not
reconcile to the .car on disk. Compare downloadSize against the IPA listing and
installSize against the unzipped app.
Source files (
.swift,.cppand the rest) are never in the IPA — they are compiled into the binary. LinkMap source-file sizes therefore contribute to install size only, and cannot contribute to download size at all.
Download size and install size are the same bytes in two states: what the user fetches, compressed, and what the device stores, uncompressed. They differ by the app's compression ratio, which is often a factor of three, so which one you are looking at changes the answer. A control at the top right of the Insights tab switches between them.
It drives the charts that have both figures — Largest Modules and the App Size Treemap.
Largest Resources is not one of them, because it has no second figure to switch to.
The bundle's files are recorded compressed and nothing else, while a catalog asset is
recorded unpacked and nothing else, so a compressed rendering of that list could only
tell you what a .car cost — which is not a thing anyone can act on.
Four things deliberately do not follow it, and are labelled with their unit instead:
- Largest Source Files is uncompressed. Source files are compiled into the binary and never exist in the IPA, so there is no compressed figure to switch to — not a gap in the report, the bytes do not exist.
- Largest Resources is mixed, and says so in its heading. The files in the bundle are compressed and the catalog renditions are the same bytes expanded, so one ranked list holds both. Its rows' tooltips say which is which.
- The Resource Type Breakdown is compressed. No per-type uncompressed figure is
recorded, only per-type compressed. Its "Other" wedge is the residual against that
compressed total, so it is the files no category claims — the
.carfiles and the untyped remainder — and not the app's compression delta. - Neither User Impact card follows it. A transfer happens over the compressed IPA and an install occupies the uncompressed one, so each card has exactly one honest figure.
A statically linked module does not move between the two, because it has no compressed size of its own; it shows its LinkMap figure in both, which is the same trade described under Statically linked modules.
Note that switching the whole page does not make the module list reconcile against
totalPackageSize: summing download sizes across modules over-counts the IPA, because a
statically linked module's bytes are already inside the app binary's compressed size.
A module with no container in the IPA — a Swift package linked straight into the app
binary, or a system library such as libdispatch.dylib — has no compressed size of its
own, so there is no download figure to measure. These are reported using their LinkMap
size instead, which is uncompressed.
That figure is real, but note what it means: the bytes are physically inside the app
binary and are already counted in its compressed download size. Summing download
sizes across an app therefore over-counts against the IPA, by the combined size of its
statically linked modules. A SwiftPM-heavy app will show a total noticeably larger than
the archive. This is a deliberate trade: a per-module figure you can act on is worth more
here than a sum that reconciles, since the per-module number is what tells you which
module to go and shrink. Use totalPackageSize — read straight from the IPA file — when
you need the real download size of the app.
- Compressed (in IPA): What users download from App Store
- Uncompressed: Actual size on device after installation
- Binary sizes (from LinkMap): Always uncompressed executable code
- Asset compression: Varies by file type (PNG, JPEG, etc.)
{
"appInfo": {
"appName": "MyApp",
"appModuleName": "MyApp",
"version": "1.2.3",
"buildNumber": "45",
"bundleIdentifier": "com.company.myapp"
},
"modules": {
"CoreModule": {
"name": "CoreModule",
"owner": "Core Team",
"additionalOwners": ["Design Systems"],
"internal": true,
"version": "2.1.0",
"binarySize": 1234567,
"imageSize": 234567,
"imageFileSize": 345678,
"proguard": 2345678,
"resources": {
"png": { "size": 123456, "count": 42 },
"storyboardc": { "size": 45678, "count": 3 }
},
"top": {
"Assets.car": 98765,
"Background.png": 12345
}
}
},
"totalPackageSize": 12345678,
"totalInstallSize": 23456789
}Field descriptions:
| Field | Unit | Description |
|---|---|---|
binarySize |
bytes | Compiled code size (from LinkMap, so uncompressed; the IPA's compressed size without one) |
binaryCompressedSize |
bytes | Compressed size of the main binary, straight from the IPA |
imageSize |
bytes | Compressed image assets in IPA |
imageFileSize |
bytes | Uncompressed image assets, including the per-asset breakdown of any .car |
proguard |
bytes | Total uncompressed module size |
resources |
object | File types with size and count |
top |
object | Files in the module, keyed by path, with their compressed size. Excludes the main binary, which is reported as binaryCompressedSize |
additionalOwners |
array | Co-owners, when an entry lists more than one |
staticallyLinked |
bool | The module has no container in the IPA; its code is linked into the app binary. It has no compressed size of its own, so downloadSize falls back to binarySize (uncompressed) rather than reporting 0 |
assetCatalogFiles |
object | Assets unpacked from this module's .car catalogs, keyed by name, uncompressed. Display only — never part of any total |
totalPackageSize |
bytes | IPA file size (compressed) |
totalInstallSize |
bytes | Installed app size (uncompressed) |
A single self-contained file. Everything it needs, including the charting library, is embedded, so it opens offline with no network access.
Interactive web interface with:
- 🔍 Search and filter modules by name
- 📊 Sort by download size, install size, or name
- 📂 Expandable module details with per-file breakdowns
- 🎨 Resource breakdowns by file type
- 📈 App-wide top-20 lists for largest modules, source files and assets
- 👥 Owner breakdown with drill-down
- 🏷️ Internal vs external module filtering
pipeline {
agent any
stages {
stage('Build App') {
steps {
// Your app build steps here
sh 'xcodebuild -configuration Release ...'
}
}
stage('Analyze App Size') {
steps {
// Clone and build Caliper
dir('caliper') {
git url: 'https://github.com/kibotu/caliper.git'
sh 'swift build -c release'
}
// Run analysis
sh '''
caliper/.build/release/caliper \
--ipa-path build/MyApp.ipa \
--link-map-path build/LinkMap.txt \
--ownership-file config/module-ownership.yml \
--package-resolved-path Package.resolved
'''
// Archive reports
archiveArtifacts artifacts: 'report.json,report.html', allowEmptyArchive: false
// Publish HTML report
publishHTML([
reportDir: '.',
reportFiles: 'report.html',
reportName: 'App Size Report',
keepAll: true,
alwaysLinkToLastBuild: true
])
}
}
stage('Check Size Thresholds') {
steps {
script {
// Parse JSON and check thresholds
def report = readJSON file: 'report.json'
def maxSize = 100 * 1024 * 1024 // 100 MB
if (report.totalPackageSize > maxSize) {
error "App size ${report.totalPackageSize} exceeds threshold ${maxSize}"
}
}
}
}
}
}name: App Size Analysis
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
analyze:
runs-on: macos-13
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '15.0'
- name: Build App
run: |
xcodebuild -workspace MyApp.xcworkspace \
-scheme MyApp \
-configuration Release \
-archivePath build/MyApp.xcarchive \
archive
xcodebuild -exportArchive \
-archivePath build/MyApp.xcarchive \
-exportPath build \
-exportOptionsPlist ExportOptions.plist
- name: Checkout Caliper
uses: actions/checkout@v4
with:
repository: kibotu/caliper
path: caliper
- name: Build Caliper
run: |
cd caliper
swift build -c release
- name: Analyze App Size
run: |
caliper/.build/release/caliper \
--ipa-path build/MyApp.ipa \
--link-map-path build/LinkMap.txt \
--ownership-file config/module-ownership.yml \
--package-resolved-path MyApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
- name: Upload Reports
uses: actions/upload-artifact@v4
with:
name: size-reports
path: |
report.json
report.html
retention-days: 90
- name: Comment PR with Size
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const report = JSON.parse(fs.readFileSync('report.json', 'utf8'));
const sizeMB = (report.totalPackageSize / 1024 / 1024).toFixed(2);
const installMB = (report.totalInstallSize / 1024 / 1024).toFixed(2);
const body = `## 📊 App Size Report
- **IPA Size:** ${sizeMB} MB
- **Install Size:** ${installMB} MB
- **Modules:** ${Object.keys(report.modules).length}
[View detailed report](../actions/runs/${context.runId})`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body
});Caliper requires the following command-line tools to be installed:
| Tool | Usage | Installation | Version Check |
|---|---|---|---|
| Swift | Build Caliper | Xcode Command Line Tools | swift --version |
| unzip | Extract IPA files | Pre-installed on macOS | unzip -v |
| xcrun | Run Xcode tools | Xcode Command Line Tools | xcrun --version |
| assetutil | Parse .car asset catalogs | Part of iOS SDK | xcrun --sdk iphoneos assetutil --version |
xcode-select --install# Check Swift
swift --version
# Should show: Swift version 6.0 or later
# Check unzip
which unzip
# Should show: /usr/bin/unzip
# Check xcrun
xcrun --version
# Should show: xcrun version X.X
# Check assetutil availability
xcrun --sdk iphoneos assetutil --version 2>/dev/null && echo "✅ assetutil available" || echo "❌ assetutil not found"- macOS 14.0 or later
- Xcode 16.0 or later
- Swift 6.0 or later
- Xcode Command Line Tools (includes unzip, xcrun, assetutil)
Before diving into analysis, make sure you have the right build configuration. Here's a quick checklist to maximize the value you'll get from Caliper:
Build Settings (for Release configuration):
-
LD_GENERATE_MAP_FILE=YES(enables LinkMap) -
DEAD_CODE_STRIPPING=YES(removes unused code) -
STRIP_INSTALLED_PRODUCT=YES(strips debug symbols) -
STRIP_SWIFT_SYMBOLS=YES(removes reflection metadata) -
SWIFT_OPTIMIZATION_LEVEL=-Osize(if size > speed) - Build configuration = Release (Debug builds are much larger!)
Build Command:
- Using
CODE_SIGNING_REQUIRED=NOfor faster CI builds - Building with
ONLY_ACTIVE_ARCH=NOto include all architectures - Using
-sdk iphoneos(not simulator)
Optional but Valuable:
- Create
module-ownership.ymlto map modules to teams - Create
package-name-mapping.ymlif you use forked dependencies - Save Package.resolved for version tracking
"I can't find the LinkMap file!"
- Make sure you built with Release configuration (not Debug)
- Check that
LD_GENERATE_MAP_FILEis set toYESin Build Settings - Clean your build folder and rebuild to ensure it generates fresh
- Use this command to locate it:
find ~/Library/Developer/Xcode/DerivedData -name "*LinkMap-normal-arm64.txt" -type f - The path varies between regular builds and archives—check both locations mentioned earlier
- Verify you're building for device (
-sdk iphoneos), not simulator
"My build fails with code signing errors"
- If using unsigned builds, check for required entitlements (HealthKit, Apple Pay, iCloud, etc.)
- These entitlements require proper code signing—you can't skip it
- Options:
- Create a separate "size-analysis" scheme without these entitlements
- Use proper code signing (slower but necessary)
- Temporarily remove the capabilities for analysis builds
"My binary size seems wrong or too small"
- LinkMap only shows compiled code, not resources or embedded frameworks
- Make sure you're analyzing arm64 (device), not x86_64/arm64 (simulator)
- Debug builds are 2-3x larger than Release—always use Release for analysis
- If the number seems too small, you might be missing dynamic frameworks
- Universal builds include multiple architectures—check you're analyzing the right one
"Asset sizes don't match what I expect"
- Asset catalog sizes shown by
assetutilare uncompressed (installed size on device) - IPA sizes are compressed (download size from App Store)
- Both numbers are correct, they just measure different things
- App Thinning further reduces what users actually download
- Example: 50 MB assets → 30 MB in IPA → 20 MB after thinning for specific device
"Modules aren't matching teams in the ownership report"
- Check your
module-ownership.ymlpatterns—are they specific enough? - Module names might not match what you expect—check the report to see actual names first
- Wildcards are your friend:
*Feature*is more forgiving than exact matches - Order matters: more specific patterns should come before generic ones
- SPM packages often have different module names than repository names
"The HTML report won't open or looks broken"
- Make sure you're opening
report.htmlin a modern browser (Chrome, Firefox, Safari) - The report is self-contained;
report.jsondoes not need to sit beside it - Some browsers block local file access—try hosting it with
python3 -m http.serverand open via localhost - If the JSON is very large (>50 MB), it might be slow to load—be patient
"Caliper crashes or hangs"
- Very large LinkMap files (>500 MB) can be slow to parse—give it time
- Make sure you have enough RAM (LinkMap parsing can use 2-3 GB for large apps)
- Check that your input files aren't corrupted (try opening them manually)
- If analyzing a massive app (>1 GB), expect analysis to take several minutes
Solution: Install full Xcode (not just Command Line Tools):
# Install from Mac App Store or:
xcode-select --install
sudo xcode-select --switch /Applications/Xcode.appSolution: Enable LinkMap generation in Xcode:
- Project Settings → Build Settings
- Search: "Write Link Map File"
- Set to:
YES - Clean and rebuild
Solution: Use --package-mapping-file to map module names to package identities.
Solution: The tool processes files sequentially. Progress is shown in the terminal. For very large IPAs (>500MB), analysis may take 2-5 minutes.
Inspired by Spotify's Ruler - adapted for iOS with native Swift implementation and iOS-specific features like asset catalog parsing and LinkMap analysis.
Contributions welcome! This tool helps iOS teams monitor and optimize app size metrics.
Copyright 2025 Jan Rabe & CHECK24
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
