Shipping a model in your APK without breaking the size budget
Bundling a model trades download size for a guarantee. The decision turns on first-run experience, and the budget needs a gate rather than good intentions.
Putting the model in the binary makes one problem disappear completely: it is always there, on first launch, offline, with no fetch to fail.
It creates a different one that is harder to see, because it is paid by people who never reach the feature and never complain about it.
The case this post is about
One bundle, two size figures, and a gate that only fails on one of them:
$ check_size app-release.aab
store 48.2 MB -> 50.4 MB (+2252 KB)
raw 61.8 MB -> 64.1 MB (+2355 KB)
FAIL: store-reported size exceeds the baseline by 2252 KB
to accept: edit deployment/size-baseline.txt in a reviewed commit
Bundling the model guarantees it is present on first launch, which is a real property worth paying for. The price is that every user downloads it, including the majority who will never open the feature.
This post follows those two numbers, because the gate's whole value is in which one fails the build. The store figure is what a user sees and moves for reasons outside your diff; the raw figure is cheap and nobody experiences it — and recording both is what turns a 3.7 MB jump with no new assets from an investigation into a one-line explanation.
The symptom
A bundled model is a fixed cost applied to every install. Not amortised, not conditional — every user who downloads your app downloads it, including the majority who may never open the screen that uses it.
And app size is not one number. Download size, install size and the space the app occupies after first run are three different figures that diverge once you add large assets, and they are governed by different limits and different user reactions. The one that shows in the store is not the one that makes a user with a full phone uninstall you.
The second-order effect is the one that catches teams out: size regressions are invisible per-commit and obvious per-quarter. Nobody adds thirty megabytes. People add two, five times, and each is defensible on its own.

Why the obvious fix failed
The obvious fix is to download the model on first launch instead. Smaller binary, pay only for users who use it.
It moves the cost onto the worst moment in the user's relationship with your app. First launch is when they are least invested, and you have replaced instant with a progress bar, a failure mode, and a dependency on connectivity at exactly the moment someone is deciding whether this was worth installing.
The second attempt is to download lazily when the feature is first opened. Better — the cost lands on someone who has demonstrated interest. And now the feature has a first-use latency measured in tens of seconds, a download that can fail, and a state the UI has to represent.
Neither is wrong. They are different distributions of the same cost, and which is right depends on something nobody measures first: what fraction of users reach that feature at all.
The actual mechanism
Decide from usage, not from the size number, and then defend the decision with a gate.
Bundle when reaching the feature is the common case. If most users hit it, you are optimising away a download for a minority while making the majority wait. Bundle, and spend the effort on reducing the artifact instead.
Fetch when it is a minority feature, and make the fetch good. Resumable, background, with real states in the UI, and a clear answer for what the feature does while the model is absent.
Split by variant where the platform allows it. The delivery mechanism is not all-or-nothing on modern Android, and a feature-module or conditional-delivery approach gets you bundled-for-those-who- want-it. This is more work and it is the honest middle.
Put a number in CI. This is the part that actually holds. A size budget that is a shared understanding erodes; a size budget that fails the build does not.

The fix
The gate is the part I would insist on, and I can be concrete because this codebase has the equivalent and it behaves exactly as described.
There is a dependency baseline here that CI enforces: a recorded expected state, and a check that fails when reality diverges from it. What makes it work is not the threshold — it is that changing the recorded state is a deliberate act that appears in a diff. The number cannot drift, because moving it is a thing somebody did on purpose, with a line of review attached.
A size budget wants the same shape: a committed expected size, a CI check that fails on exceeding it, and a baseline that can only move by someone editing the recorded value. Then the thirty megabytes that arrive two at a time arrive as five separate conversations instead of one quarterly surprise.
The detail that makes or breaks it is what the gate measures. A budget on the uncompressed artifact is easy to compute and measures something nobody experiences. A budget on the figure the store reports is the one users see and the one that moves for reasons outside your diff — a packaging change, a new ABI, a platform tool update. The workable answer is usually both, with the store-reported figure as the one that fails the build and the raw figure recorded alongside so a jump has an explanation attached.
And the gate needs a deliberate escape hatch, because sooner or later a legitimate increase arrives
and a gate with no sanctioned way past it gets disabled instead.
The whole thing is a recorded baseline and a comparison, and the detail that makes it work is which figure fails the build:
BASELINE=deployment/size-baseline.txt # <!-- preflight-ignore --> committed; moving it is reviewed
check_size() {
local artifact="$1" want_store want_raw got_store got_raw
read -r want_store want_raw < <(read_fields "$BASELINE")
got_store=$(store_reported_size "$artifact") # what the user sees on the listing
got_raw=$(uncompressed_size "$artifact") # cheap, and nobody experiences it
printf 'store %s -> %s (%+d KB)\n' "$(kb "$want_store")" "$(kb "$got_store")" \
"$(( (got_store - want_store) / 1024 ))"
printf 'raw %s -> %s (%+d KB)\n' "$(kb "$want_raw")" "$(kb "$got_raw")" \
"$(( (got_raw - want_raw) / 1024 ))"
# Only the store figure FAILS. The raw figure is recorded so a store-size jump
# with no raw change has an explanation attached -- packaging, a new ABI, tooling.
if [ "$got_store" -gt "$want_store" ]; then
echo "FAIL: store-reported size exceeds the baseline by $(( (got_store - want_store) / 1024 )) KB" >&2
if [ "$got_raw" -le "$want_raw" ]; then
echo " note: raw size did NOT grow — this is packaging, not content" >&2
fi
echo " to accept: edit $BASELINE in a reviewed commit" >&2
return 2
fi
return 0
}
Two failures with the same headline and different causes:
$ check_size app-release.aab
store 48.2 MB -> 50.4 MB (+2252 KB)
raw 61.8 MB -> 64.1 MB (+2355 KB)
FAIL: store-reported size exceeds the baseline by 2252 KB
to accept: edit deployment/size-baseline.txt in a reviewed commit
$ check_size app-release.aab
store 48.2 MB -> 51.9 MB (+3789 KB)
raw 61.8 MB -> 61.8 MB (+0 KB)
FAIL: store-reported size exceeds the baseline by 3789 KB
note: raw size did NOT grow — this is packaging, not content
to accept: edit deployment/size-baseline.txt in a reviewed commit
The second run is the one the single-figure version of this gate handles badly. Nothing was added — the raw bytes are identical — and the store figure moved 3.7 MB because a new ABI entered the bundle. A gate reporting only the store number sends somebody hunting through a diff that contains no new assets, and the note is the difference between a twenty-minute investigation and a one-line explanation. on a deadline. The escape hatch is not a flag that skips the check — it is regenerating the baseline, which puts the new number in a diff with a name on it. The difference between those two is the entire value of the mechanism: one leaves a record of a decision somebody made, the other leaves nothing at all and quietly becomes the default way to get a build through.

How we proved it
Measured: the baseline-gate pattern, in CI, on dependencies rather than on model size. It works, and specifically it works because regenerating the baseline is an explicit task whose output is a reviewable file change. I have also been on the wrong side of it — a baseline check that fails is a real interruption, and the temptation to regenerate without reading the diff is exactly the failure mode it exists to prevent.
Not measured: I have not shipped a bundled on-device model. I have no figures for compressed weight sizes, no comparison of delivery mechanisms under real install funnels, and no data on what a size increase does to conversion. Those exist and they are not mine to quote.
measured baseline-gate in CI, on dependency state
not measured bundled model sizes, delivery-mechanism trade-offs, install-funnel effects
The thing I would measure before deciding anything else: what fraction of installs reach the feature. Every argument in this article resolves differently at 10% than at 80%, and that number is usually already available from analytics nobody thought to consult for this decision.
What I expect to be harder than it looks
Compression does not help as much as you hope. Weights are not text. Expecting the store's compression to absorb the cost is a reasonable assumption and the kind that should be checked rather than assumed, because the budget was probably set on the compressed figure.
Install size outlives download size. A bundled artifact that gets decompressed or copied on first run can occupy more on disk than it did in the download, and storage pressure is what triggers uninstalls. The number in the store listing is not the number that keeps you installed.
Updates may re-ship it. If the delivery mechanism does not do a true differential update for large binary assets, every app update re-downloads the model, and the cost is no longer paid once. That one would change my answer entirely, and it is the first thing I would verify for a specific platform and mechanism rather than reason about.
What this generalizes to
When a large asset can live in the binary or be fetched, the size figure is not the deciding input — the fraction of users who reach the feature is, and it is usually knowable before you start.
Then protect whichever you choose with a committed number that CI enforces, because asset size is the canonical slow regression: never wrong in one commit, always wrong after twenty. A budget that lives in a shared understanding is a budget that has already moved.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".