Slim Release — Cutting the iOS bundle from 45 MB to 19.8 MB

Copy Markdown View Source

This guide is the working record of how the Mob iOS bundle went from 45 MB (the size that kept TestFlight rejecting builds for being "unnecessarily large") down to 19.8 MB — a 56% reduction, all without dropping a single feature.

It also documents the toolchain we built along the way, why the defaults are asymmetric between dev and release, and how to bisect a broken build using the per-step [SLIM:<tag>] traceability markers.

The decision to eventually extract this work as a standalone Hex package (lean_release) is tracked in lean_release_extraction.md. For now, everything ships inside mob_dev.


TL;DR

mix mob.deploy             # dev iteration — slim OFF (fast, ~2-3s saved per build)
mix mob.deploy --slim      # dev iteration — slim ON (verify before TestFlight)
mix mob.release            # App Store / TestFlight — slim ON
mix mob.release --no-slim  # App Store / TestFlight — slim OFF (debugging only)

Search for [SLIM:<step>] in the build log to see how many KB each strip pass shaved off. If a step breaks the build, this is your bisect log.


The 45 → 19.8 → ?? MB journey

BuildIPA sizeWhat changed
Before45 MBVanilla mix mob.release — full OTP runtime, no strips
Step 137 MBApple-policy strips made traceable + made always-on
Step 227 MBAdded prefix_libs, foreign_apps, dedup_versions, src_and_headers
Step 319.8 MBbeam_chunks strip (Dbgi/Docs chunks, :beam_lib.strip_release/1)
Step 4TBDPass 1: C-side -Os -ffunction-sections -fdata-sections + --gc-sections

Pass 1: C-side dead-section elimination (2026-05-06)

Inspired by the GRiSP nano writeup (2025-06-11) where the same flags were the single biggest C-side shrink for fitting the BEAM into 16 MB of OctoSPI DRAM. The mechanic:

  • -Os (size-optimised) over the default -O2 / -O3.
  • -ffunction-sections -fdata-sections puts every function and global data object in its own ELF/Mach-O section.
  • -Wl,--gc-sections (GNU ld, used on Android) and -Wl,-dead_strip (ld64, used on iOS) drop sections nothing references at link time.

Together they let the linker delete unused functions from libbeam.a, libcrypto.a, our crypto.a static NIF wrapper, and our own libpigeon.so / iOS app binary. This is the C-side analog of what beam_lib:strip_release/1 does for .beam files.

Applied to:

  • All four OTP cross-compiles (xcomp/erl-xcomp-*-android.conf, xcomp/erl-xcomp-arm64-ios{,simulator}.conf).
  • All four OpenSSL cross-compile scripts in scripts/release/openssl/.
  • The custom build_crypto_static_*.sh scripts that recompile crypto's NIF C sources with -DSTATIC_ERLANG_NIF -fPIC.
  • The Android CMakeLists.txt template in mob_new and mob_dev/lib/mob_dev/native_build.ex's generated iOS-device link.
  • The iOS build.sh.eex template (sim-build link).

Size delta recorded in ~/code/mob/crypto_plan.md once the rebuild + republish cycle completes.

Sample [SLIM:...] log from a recent release build:

[SLIM:prefix_libs]    120824 KB  74756 KB   (-46068 KB)
[SLIM:foreign_apps]    74756 KB  74020 KB     (-736 KB)
[SLIM:dedup_versions]  74020 KB  64012 KB  (-10008 KB)
[SLIM:src_and_headers] 64012 KB  47840 KB  (-16172 KB)
[SLIM:beam_chunks]     47840 KB  27552 KB  (-20288 KB)

Note that the first column above is the OTP runtime tree (~120 MB unzipped on disk), not the IPA. The IPA is the codesigned .app zipped with ditto after these strips, plus the main Mach-O.


What gets stripped (and why)

Every step is a separate bash invocation routed through the slim_step <tag> <command> helper. Each prints a tagged size delta so the build log makes the impact of each step obvious — and so a regression in a single step can be bisected from the log alone.

apple_binaries (always on, never gated)

Apple's App Store validator (error 90171) rejects bundles with .so, .a, or any standalone Mach-O other than the CFBundleExecutable. We strip all of these from the bundled OTP runtime. This is the one strip that runs even with --no-slim — it's not optional for shipping to TestFlight or the App Store.

What goes:

  • *.so and *.a everywhere under OTP_BUNDLE
  • priv/bin/* (memsup, cpu_sup, et al.)
  • erts-*/bin/* (erl_call, erlexec, et al.)

prefix_libs

Strips OTP applications by name prefix that no Mob app ever loads. The biggest single saving — typically 40–50 MB.

The current strip set:

megaco runtime_tools erl_interface os_mon wx et eunit
observer debugger diameter edoc tools snmp dialyzer
syntax_tools parsetools xmerl reltool inets ftp tftp
common_test mnesia eldap odbc
compiler ssh                                     # 2026-05-06

The 2026-05-06 additions came from Mob.Diag.loaded_snapshot/0 against a running pigeon iOS-sim build: 0 of 59 compiler-9.0.5 modules and 0 of 43 ssh-5.5.1 modules ever loaded. ~4.4 MB win, no observed regressions on either platform. Risk floor: any mob app that calls Code.eval_string/1, Code.compile_string/1, :erl_eval.eval_str/1, or starts an :ssh client/server breaks. None of the apps in this tree do; new apps with those needs need to drop the strip in MobDev.Release / MobDev.NativeBuild.

Empirical-snapshot-driven additions are the way. Apps that shouldn't have made the strip set show up in the next loaded_snapshot diff. The dance: deploy → mob.snapshot_loaded → see what's loaded that shouldn't be vs. shipped that's never used → iterate.

Adding to this list is a one-line change in lib/mob_dev/release.ex (and the matching lib/mob_dev/native_build.ex for dev parity, plus the test list in test/mob_dev/release_script_test.exs). Any prefix on this list will balloon the bundle if removed — only do that if a specific app actually depends on it at runtime.

foreign_apps

If you've ever run mix mob.release from a checkout that shares a dep cache with another project, you may end up with toy_*, test_*, mob_test, or scratch_* apps shipping in the bundle. These are not OTP apps and definitely not your app — they leaked in from another release tree. This strip drops them.

dedup_versions

asn1-5.4 and asn1-5.4.3. public_key-1.18 and public_key-1.20.2 and public_key-1.20.3. The OTP runtime cache accumulates older versions of libraries when you upgrade. Only the newest version is ever loaded; older versions are dead weight. This step keeps the highest version of each library and removes the rest.

The duplicate detection logic is in MobDev.OtpAudit.collapse_duplicates/1 — covered by unit tests in test/mob_dev/otp_audit_test.exs.

src_and_headers

src/*.erl and include/*.hrl are compile-time only. They are not loaded at runtime. They sit in the bundle out of habit because OTP's release packager copies the whole library directory. We drop them.

This is a ~16 MB win on a typical Mob app.

beam_chunks

:beam_lib.strip_release/1 removes the Dbgi, Docs, Atom, and Locals chunks from every .beam file in the bundle. Roughly 30% saved per .beam — usually 15–25 MB total across the OTP runtime tree.

Mix has a strip_beams: true release option that does the same thing, but mix mob.release does not go through mix release (we build a custom .ipa that bypasses Mix's release packaging entirely), so we call :beam_lib.strip_release/1 directly on the bundle.

xcrun strip -x (main binary symbol strip)

Run after the OTP strips, before codesigning. Removes non-global symbols from the main Mach-O. Smaller win (~1–2 MB) but mandatory for clean App Store submission.


Asymmetric defaults (dev OFF, release ON)

The slim pass adds 5–10 seconds per build:

  • :beam_lib.strip_release/1 spawns an erl process and walks every .beam in the bundle.
  • xcrun strip -x rewrites the main Mach-O.
  • Cache cleanup (dedup_versions, foreign_apps) does I/O across the full lib tree.

For dev iteration, that's 5–10 seconds you don't get back. For App Store delivery, that's 5–10 seconds against a 20-minute TestFlight round-trip plus the cost of confusing testers with a second build number — easy trade.

So the defaults are flipped between paths:

TaskSlim defaultOverride flag
mix mob.deployOFF--slim
mix mob.deploy --nativeOFF--slim
mix mob.releaseON--no-slim

The opt-in for dev exists so you can verify a slim build runs on device before you round-trip it through TestFlight. The opt-out for release exists for the case where you're debugging a strip-induced regression and need symbols + Dbgi chunks to do it.


Plumbing

The slim flag travels from the Mix task to the bash script through three hops:

  1. mix mob.deploy / mix mob.release parse --slim / --no-slim, defaulting to false / true respectively.
  2. The Mix task calls MobDev.NativeBuild.build_all(slim: bool) (dev) or MobDev.Release.build_ipa(slim: bool) (release).
  3. Both stash the value in the process dictionary (Process.put(:mob_slim, bool)), and the env-var builder reads it into MOB_SLIM=0 or MOB_SLIM=1.
  4. The generated bash script gates the slim block on if [ "${MOB_SLIM:-1}" = "1" ]; then (release default 1) or if [ "${MOB_SLIM:-0}" = "1" ]; then (dev default 0).

The bash default differs between paths so that if MOB_SLIM is unset entirely (e.g. someone runs the generated script by hand), the right behavior happens for that path.


Per-step traceability — the [SLIM:<tag>] log

Every strip step is wrapped with the slim_step bash function:

slim_step() {
    local label=$1
    local before=$(du -sk "$OTP_BUNDLE" 2>/dev/null | awk '{print $1}')
    shift
    "$@"
    local after=$(du -sk "$OTP_BUNDLE" 2>/dev/null | awk '{print $1}')
    local delta=$((before - after))
    printf "[SLIM:%s] %s KB → %s KB  (-%s KB)\n" "$label" "$before" "$after" "$delta"
}

Sample output:

[SLIM:prefix_libs] 120824 KB  74756 KB  (-46068 KB)

If a build breaks and the breakage is in the slim pass, the [SLIM:<tag>] log tells you exactly which step did it. The build log should be the first place you look:

grep '\[SLIM:' build.log

If the failing step's [SLIM:<tag>] line is missing entirely, the breakage happened during that step — look at the lines just above the next [SLIM:...] line for the actual error. If every step is present but the build still fails, the breakage is downstream (codesigning, ditto packaging, plist surgery).


Tool inventory

These all live under mix mob.* and are documented per-task with mix help <task>. The slim build itself is always-on infrastructure; the rest are diagnostic tools you reach for when investigating bundle size or stripping aggressiveness.

mix mob.audit_otp

Static reachability analysis. Walks every .beam file under an OTP root, extracts the imports chunk, computes the transitive closure from your app's entry-point modules. Anything not reachable is a strip candidate.

mix mob.audit_otp

The output reports unreachable libraries (candidates for the prefix_libs set), duplicate library versions (caught by dedup_versions), and foreign apps from cache pollution (caught by foreign_apps).

This is how the original 10 MB of cruft was found.

Implementation: MobDev.OtpAudit (covered by unit tests in test/mob_dev/otp_audit_test.exs).

mix mob.trace_otp

Empirical trace of what an app actually loads at runtime. Wraps :erlang.trace_pattern/3 to capture every MFA called by a synthetic harness that exercises the basic Elixir/OTP surface (collections, strings, processes, OTP behaviours, errors). The result is a set of 59 modules / 447 MFAs that we treat as the floor — anything not in this set is at least worth questioning.

This is the empirical complement to mix mob.audit_otp's static analysis. The static call graph misses dynamic dispatch (apply/3, GenServer callbacks, behaviour callbacks, hot-loaded modules); the trace catches them.

mix mob.verify_strip

Eager-loads every .beam file in the deployed bundle on a connected device (via Erlang distribution). If the strip pass dropped something the app can no longer load, this surfaces it before a user does.

mix mob.connect      # in one terminal
mix mob.verify_strip # in another

Implementation: Mob.Diag.verify_loaded_modules/0 (lives in mob itself, not mob_dev, so it ships in every app).

mix mob.snapshot_loaded

Snapshot of which modules are actually loaded right now on a connected device, plus the list of .beam files in the bundle that have NOT been loaded. Useful for spotting strip candidates that slipped through audit_otp because the import graph was wrong, or for confirming a rare code path actually loads what you expected.


Tests

Coverage as of 2026-05-02:

FileTestsCovers
test/mob_dev/release_script_test.exs29Bash shape: Apple-policy strips, slim_step helper, [SLIM:tag] markers, codesign
test/mob_dev/otp_audit_test.exs10Synthetic OTP tree: discovery, dedup, foreign-app detection, size accounting
test/mob_dev/otp_asset_bundle_test.exs7Default strip set sanity, build/3 error paths
test/mob_dev/otp_trace_test.exs4Trace harness phases, MFA collector normalization

Run the slim-related subset:

mix test test/mob_dev/release_script_test.exs \
         test/mob_dev/otp_audit_test.exs \
         test/mob_dev/otp_asset_bundle_test.exs \
         test/mob_dev/otp_trace_test.exs

Gaps still on the list (none blocking):

  • Mob.Diag.verify_loaded_modules/0 lives in the mob repo and needs a connected device; no synthetic-fixture test yet.
  • Mob.Diag.loaded_snapshot/0 same.
  • Live-device round-trip of mix mob.verify_strip against a slim build, blocked at time of writing by an eaddrinuse flake on the EPMD tunnel.

Adding a new strip step

The pattern is:

  1. Decide what to strip and why. Look for prior art in MobDev.OtpAudit reports — most strip ideas come from finding something unreachable that nobody noticed.
  2. Add the step to MobDev.Release.release_device_sh/0 (release path) AND MobDev.NativeBuild.generate_build_device_sh/2 (dev path), wrapped in slim_step <tag> ....
  3. Add a string-shape test in test/mob_dev/release_script_test.exs under the "MOB_SLIM gating and per-step traceability" describe block — every tag in the strip set should appear in the Enum.each list for "every strip step routes through slim_step".
  4. Update the table at the top of this guide with the new size delta.

The dev and release paths must stay in lockstep on which strips run (both gated on MOB_SLIM); divergence means a slim build that works on the dev path can break on TestFlight, which is exactly the trap this asymmetric-defaults setup is designed to avoid.