On-Device AI

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.

MobileByteSensei 8 min read
Everyone downloads it; some of them use it
on-deviceandroidrelease

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.

One number everyone quotes, three numbers that exist

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.

Where the cost lands under each delivery choice

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.

A number that drifts, and a number that has to be edited

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".

Follow by RSS