Universal archive. Every slice, every scale. Nobody downloads this.
Where do
the bytes go?
Making iOS app growth observable at scale.
By Jan Rabe: technical team lead for CHECK24's native apps for six years, now lead developer (full-stack web). A 20-minute talk from swiftCon 2026 about making iOS app size explainable: which module, which file, which owner, on every build. The examples come from two real apps. Module, team, vendor, and file names are anonymised.
195.5MB
4.5 MB below the 200 MB cellular prompt.
Wrong number. Wrong question.
Our comparison app's IPA is 195.5 MB. It looks like it's 4.5 MB from the point where iOS asks before a cellular download. It isn't. The IPA is not what a user downloads. And "how big?" is the wrong question anyway. The useful question is "whose bytes, and since when?"
Three numbers wearing one name.
Unzipped. The binary grows 2.6×; asset catalogs barely move.
Size Report
Apple's ground truth for how much. Signed export. Not who.
Pick one. Write it down. Never compare across. (Not even across platforms.)
"App size" means three different numbers. The compressed IPA (195.5 MB) is universal and nobody downloads it as-is. The installed size (409 MB) is what sits on disk. The per-device thinned size comes from Apple's App Thinning Size Report, and it is the number the App Store's cellular prompt cares about. This talk uses IPA bytes of a Release device build, because CI can measure them on every build. The same app on Android reports 119.2 MB download for one device spec. That is a different question, not a smaller app.
The IPA already knows.
unzip -v App.ipaevery file · compressed vs. installedxcrun assetutil --info Assets.carevery rendition · scale · appearanceLD_GENERATE_MAP_FILE = YESevery symbol → object file → modulemodule-ownership.ymlthe one file Xcode can't writeThe 195.5 MB IPA splits into one statically linked app binary (89.6 MB, 46%), 63 asset catalogs (69.2 MB, 35%), dynamic frameworks (22.4 MB), ML models (7.9 MB) and the rest (6.4 MB). The build already holds the attribution. unzip -v lists files, assetutil --info opens Assets.car, the LinkMap (Write Link Map File = YES) maps every symbol to a module, and an ownership file maps modules to owners.
We've been here before.
Five feature teams, one shared core. Every team: "not us."
of the app in one shared module. 96% of that module: images.
About six weeks of releases. Nobody rewrote any Swift.
The biggest Swift problem in that app was a tutorial.
This has happened before, in our smaller app in 2024. It was 95 MB, and 86% of it sat in one shared module, 96% of which was images. Unbundling, reuse, and deletion took it to 21.6 MB across about six weeks of releases, without rewriting Swift. The biggest single culprit: huge tutorial images bundled into the app. Shown once, on first launch, and downloaded by every install.
Same tool. Opposite answer.
This time, the problem is us.
In the big app, the largest first-party modules are 62–96% compiled Swift, not images. 214 MB of the 230 MB installed app binary is our own code. The 2024 playbook (find the image, unbundle it) doesn't carry over. The method does: join the artifacts and look before you guess. Ten of 77 modules hold 63% of first-party bytes.
What the report found in September.
one Instagram icon. A gradient SVG. The largest image in the app.
a Christmas promo logo, 1.33 MB, still shipping in September 2026.
Black Friday artwork across 3 modules. 1.6 MB, all year round.
the same photo of a dog, in three modules. Good dog. Three times.
asset names that ship in two or more modules. 6.4 MB.
images over 250 KB hold 17.2 MB. 0.6% of 3,598 renditions.
Even in a code-heavy app, the asset catalogs hold surprises: a 4.84 MB Instagram gradient icon (the largest image in the app), a December 2025 promo still shipping in September 2026, Black Friday artwork in three modules, the same dog photo three times, 211 asset names duplicated across modules, and 23 images holding 17.2 MB. None of these are bugs. Each one is a local decision made without seeing the total. The fixes are boring: an SVG with gradients is still an image, and gradients make SVGs huge, so rasterize it and load it remotely. Anything shown once, and any seasonal artwork, doesn't belong in the bundle.
95 → 21.6 MB. Then nothing.
The drop was a project. The flat line is a system.
The smaller app dropped from 95 MB to 21.6 MB, and a year later it was at 20.1 MB despite weekly feature work. The drop was a project. The flat line is a system. In the big app, 77 modules and 55 owners rule out a one-off cleanup. Only the system scales.
The hardest file isn't Swift.
# module-ownership.yml · first match wins
- identifier: "ProductA"
owner: Team A
internal: true
# satellites carry their product's owner
- identifier: "ProductAManifest"
owner: Team A
internal: true
# vendors get an owner, too
- identifier: "VendorSDK*"
owner: Vendor X
# Apple catch-alls go last
- identifier: "*.tbd"
owner: Apple
A number without an owner is a weather report.
Module names aren't team names. An ordered YAML file (first match wins, specific above generic, Apple catch-alls last) maps 1,659 modules to owners with 120 rules and zero unowned modules. Product satellites carry their product's owner, and vendors get an owner too. Once size has an owner, "195.5 MB" stops being a weather report and becomes a conversation.
Every release. Every module. A diff.
The dashboard tells you who.
It doesn't tell you when you could still say no.
Today the big app has a per-release dashboard: every product module, its owner, its size, and the delta since the previous release. That makes growth attributable and comparable. But it arrives a week after the merge, and the delta has no pull request attached. The next step is to move the same diff into the pull request.
Ask again on every pull request.
# .github/workflows/size.yml · macOS runner
- uses: kibotu/caliper@1.5.4
id: size
with:
ipa-path: build/App.ipa
link-map-path: build/App-LinkMap-normal-arm64.txt
ownership-file: config/module-ownership.yml
- run: scripts/size-delta.sh main.json \
${{ steps.size.outputs.report-json }}
baseline + delta + owner
+ an explicit "yes, on purpose, because…"
A red build without an owner
is a ceremonial failure.
Next: run the same analysis on every pull request. Archive an unsigned Release build with the LinkMap enabled, run Caliper (a GitHub Action exists), diff report.json against main, and post the delta, the top movers and their owners on the PR. Policy: baseline + delta + owner, plus an explicit way to accept intentional growth. No magic byte limit.
Your Android colleagues will ask.
plugins { id("net.kibotu.ruler") }
$ ./gradlew analyzeReleaseBundle
→ build/reports/ruler/release/report.{json,html}
Same app. Same report. Different question.
Compare each platform with itself.
For Android, a maintained fork of Spotify's Ruler writes the same HTML report, so one dashboard can read both platforms. The same app reports 119.2 MB on Android for one device spec and 195.5 MB as a universal IPA. Those numbers answer different questions. Compare each platform only with itself. github.com/kibotu/ruler
What I don't know yet.
- Where 214 MB of Swift goes.Generics, Codable, value types, SwiftUI? The LinkMap knows. I haven't asked it well enough yet.
- 76% of our image bytes are SVG.How many more are secretly bitmaps? Should CI flag every SVG with a gradient?
- Per-device numbers in the PR.The thinning report needs a signed export. Worth the CI minutes?
- Background Assets.Apple-hosted asset packs: should seasonal art leave the bundle entirely? Probably.
- The right budget.I don't believe in one number. Growth per feature? Per owner? Tell me.
Open questions: what makes the first-party Swift so large (generics, Codable, value types, SwiftUI); how many of the SVGs (76% of the image bytes) are really bitmaps in disguise, and whether CI should flag every SVG with a gradient; whether per-device sizes belong in every PR; whether Background Assets should host seasonal artwork; and whether any single size budget makes sense. Answers welcome.
Measure the artifact. Name an owner. Ask again on every build.
Monday: take your last Release IPA. Write down its top three contributors, and who owns them.
Slides: kibotu.github.io/swiftcon-presentation-caliper. Homework for Monday: take your last Release IPA and write down its top three contributors and who owns them. Measure the artifact. Name an owner. Ask again on every build.