All Articles
CategoryTools
Reading Time
10 min read
Published
2026-10-21
Word Count
1,624words

Grab a coffee — this one is a deep dive!

sizeledger: where every megabyte of an iOS app comes from

Summary

A Swift CLI and library that reads an .ipa, .app or .xcarchive and breaks its size down by category, framework, language and architecture.

  • sizeledger reads an .ipa, .app or .xcarchive and shows its bytes by category, framework, language and CPU architecture.
  • It only reads files: it never builds, runs, uploads or changes the app, and gives the same report on macOS and Linux.
  • It keeps three sizes apart: the files added up, an install estimate in 4 KiB blocks and the compressed size inside an .ipa.
  • The ZIP reader, DEFLATE decoder and Mach-O parser are written in Swift, so the SizeLedgerKit library has no dependencies.
  • It does not give the App Store download size, and the diff and budget commands are planned, not shipped, as of 0.1.10.
sizeledger: where every megabyte of an iOS app comes from

Table of Contents


The problem

The usual answer to "what made the app bigger?" is to unzip the .ipa and sort files by size. That answers part of it. A framework is a folder of many files, a language is a set of .lproj folders spread across the app and its frameworks, a universal binary carries a slice for every architecture, and an asset catalog is one opaque Assets.car. Apple's own numbers (Xcode's App Thinning Size Report, App Store Connect) need Apple's build pipeline and a signed build, which a pull request check on a Linux runner does not have.

sizeledger is a command-line tool and a Swift library for that gap. Point it at an .ipa, an .app or an .xcarchive and it reports where the bytes are: by category (main executable, frameworks, app extensions, App Clips, watch app, localizations, asset catalogs, code signature, other resources), by framework and resource, by .lproj language and by CPU architecture slice. It reads the files only. It does not build, run, upload or change anything, and it gives the same report on macOS and Linux.

The repository opened on 15 October 2026 and 0.1.0 shipped the same day. Ten patch releases followed by 21 October, the current one being 0.1.10; most of them harden the readers against crafted input.

Design decisions and alternatives

Three sizes, kept apart

The key decision is what not to claim. "App size" means at least three things, and sizeledger names each one:

  • Total (bytes): the sizes of the files added up, what a file manager shows.
  • Install size, estimate (installBytes): every file rounded up to whole 4 KiB blocks, the way APFS stores it. In the source this is InstallSize.estimate, with blockSize set to 4,096.
  • Compressed in the .ipa (compressedBytes): what the bundle's files take inside the archive, for .ipa input only.

None of them is the App Store's download or install size. The App Store thins the app per device (one architecture slice, only the asset images for that device), encrypts the binaries and compresses the download. A formula that guesses the store number from the upload would be off by an unknown amount, so the report calls the install size an estimate and adds notes when the input is a simulator build or holds universal binaries, the two most common reasons a device sees something smaller.

The estimate is checked rather than assumed: the tests unpack the two SampleShop fixture apps and compare against du. On a Mac (Apple silicon, macOS 27.0.1, APFS) the install estimate equals du -sk exactly; in the Linux container (swift:6.4.1-noble, aarch64) the total equals du -b --apparent-size. The tests fail above 1% on both.

No dependencies in the library

SizeLedgerKit has no package dependencies. The ZIP reader (with ZIP64), the DEFLATE decoder and the Mach-O parser (thin, fat and fat64) are written in Swift inside the library. The alternative, shelling out to unzip, lipo and otool, ties the tool to Xcode, while the point is that the measurement runs anywhere, including a Linux CI job that receives the .ipa from a macOS build job. The DEFLATE decoder is tested against zlib's output for stored, fixed and dynamic blocks, and the Mach-O parser on thin, 32-bit, fat and fat64 files. Only the command-line target depends on swift-argument-parser.

Output for people and for CI

The default output is a terminal table. --format markdown writes GitHub Markdown for a pull request comment or a job summary, and --format json writes a report with a stable key order, so it can be committed, queried with jq and compared with diff. Until 1.0 the JSON layout carries schemaVersion 0 and may change in a minor version; the changelog says when it does.

Input is untrusted

An .ipa is a file someone hands you, so sizeledger treats it as hostile. Since 0.1.4 it refuses inputs past limits on file count, path depth, declared uncompressed bytes and compression ratio, set with --max-files, --max-depth, --max-bytes and --max-ratio or, in the library, an AnalysisLimits value. In 0.1.0, a 1 MB crafted .ipa whose fat header pointed a slice 1 GB into a DEFLATE entry made the tool hold 3.77 GB in memory. Headers are now read as ranges of at most 4 KiB, and decompression keeps only the 32 KiB window that DEFLATE back-references need; the same file then took 11.5 MB on a Mac (Apple silicon) (PR #27).

A real bug: Swift strings are not file paths

The most instructive bug was not about sizes at all. Swift's String compares canonically equivalent text as equal and splits by Character, a grapheme cluster. An unzip tool and a file system do neither. Until 0.1.7, sizeledger handled entry paths as Strings, and PR #52 lists what that broke:

  • Payload/G.app/../\u{301}evil was accepted although the README says a .. component is refused. The combining mark U+0301 joins the / before it into one Character, so split(separator: "/") never produced ...
  • Payload/G.app/\u{301}big.bin, a 5 MB file, failed hasPrefix("Payload/G.app/"), landed among the files outside the bundle and left the totals.
  • café.bin in NFC and in NFD were one String dictionary key, so the second file replaced the first and its 5 MB dropped out of the totals without a note.
  • A / followed by a combining mark did not count toward --max-depth.

The fix (commit f77172f, "fix(ipa): split and compare entry paths on their bytes") moved every decision about which files exist, which folder they sit in and how deep they are onto bytes. The internal BundlePath type splits, prefixes and compares on utf8 views, and a PathKey type keys dictionaries and sets by bytes. Eight new tests in PathBytesTests failed on main before the change.

The first fix was not the whole story. In 0.1.8 (PR #55), an .ipa entry whose name is not valid UTF-8 was still decoded as Latin-1 and then identified by that text, so caf\xE9.bin and the UTF-8 café.bin became one path: an archive that unzip -l lists as 4 files and 5,000,179 B was reported as 3 files and 179 B. Identity now comes from the bytes the archive stores, PathKey(bytes:), while the shown name is still the Latin-1 reading, with a note in the report that two rows can read the same. The lesson: a path is a byte sequence shown as text, and a comparison through String equality, hasPrefix or a String-keyed dictionary decides something the file system never did.

Usage

Install with Homebrew on macOS or Linux, or download a release binary for macOS (universal) or Linux (x86_64, aarch64) and check it against SHA256SUMS:

sh
1brew install muhittincamdali/tap/sizeledger

Then measure an exported .ipa, the .app from a build or an .xcarchive:

sh
1sizeledger analyze path/to/MyApp.ipa

On the SampleShop 2.0 fixture this prints 8.75 MB in 21 files, an 8.80 MB install estimate and 2.72 MB compressed. For CI:

sh
1# Keep the report for later
2sizeledger analyze build/MyApp.ipa --out size/main.json
3 
4# Markdown in a GitHub Actions job summary
5sizeledger analyze build/MyApp.xcarchive --format markdown >> "$GITHUB_STEP_SUMMARY"

Since 0.1.10, --out does not replace an existing file: it ends with exit status 2 unless --force is given. Exit status 0 means the report was made, 2 that the input could not be read or the report could not be written, and 64 that the options were invalid.

The library gives the same report to your own tools. Add .package(url: "https://github.com/muhittincamdali/sizeledger", from: "0.1.10") and the product SizeLedgerKit. The example in Examples/LibraryUsage, which the Linux workflow builds and runs on every pull request and every push to main, starts like this:

swift
1let report: SizeReport
2do {
3 report = try SizeLedger.analyze(URL(fileURLWithPath: path))
4} catch {
5 print("error: \(Sanitize.text(String(describing: error)))")
6 exit(2)
7}
8 
9let totals = report.totals
10print("\(Sanitize.text(report.app.name ?? report.input.bundle)): \(totals.files) files")
11print(" bytes: \(ByteFormat.string(totals.bytes))")
12print(" install size (estimate): \(ByteFormat.string(totals.installBytes))")

Names, paths and notes come from the app, so they go through Sanitize.text, which replaces control characters, bidirectional overrides and characters that draw nothing.

Limits

  • Sizes are local measurements. The App Store download and install sizes depend on thinning, encryption and compression at Apple; sizeledger reports an estimate and says so.
  • Asset catalogs are counted as files. The renditions inside Assets.car are not listed yet (planned for 0.8, through assetutil on macOS).
  • Swift metadata is the size of the __swift5_* sections, a lower bound, because some metadata lives in __const.
  • A macOS .app with a Contents/ folder is measured, but its files land under "Other resources".
  • An .ipa with more than one app, encrypted entries, compression other than stored and DEFLATE, or a .. path component is refused with a message, not guessed at.
  • Within the default limits a crafted input can still cost time: the slowest one found took 15 to 24 s on a busy Mac (Apple silicon). A lower --max-bytes lowers that bound.
  • Only analyze exists today. diff (planned for 0.2), budget rules with a GitHub Action (planned for 0.3), an HTML treemap (planned for 0.8), a frozen JSON schema (planned for 1.0) and Android support (planned for 1.1) are not in the tool yet. Until budget exists, a CI limit check is one jq line on .totals.installBytes.

CI runs the 159 tests on macOS and on Linux (x86_64 and arm64); one needs a non-UTF-8 file name, which APFS refuses, so it runs on Linux only.

Source code: github.com/muhittincamdali/sizeledger

Tags

#ios#app-size#ipa#mach-o#swift#cli#linux
Muhittin Camdali

Muhittin Çamdalı

Senior Mobile Engineer

Senior Mobile Engineer with 12+ years of experience. I build mobile products with iOS, Flutter and React Native, working full-stack and AI-native.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

Share