DEV Community

Melisa Wong
Melisa Wong

Posted on

I diffed my Capacitor APK against my web build. 780 files inside it no longer existed.

I signed a release APK that was 585 MB. The web build it was supposed to contain is 182.7 MB. Compression does not explain a 3.5× gap, so I went looking — and what I found was worse than a size problem: 780 of the 1,397 files inside that APK no longer existed anywhere in my project.

Here is how the gap was made, how to spot it in about three seconds on your own app, and the guard script I now run before every signing.

The first guess was wrong in an instructive way

My instinct was "images are heavy". I listed the largest files inside the native project's copy of the web bundle:

ls -l android/app/src/main/assets/public/assets/*.js
Enter fullscreen mode Exit fullscreen mode
69240126  Exploration-BM48lXvB.js
11977882  surname_info-BqOd4Hrh.js
  369001  index-DjfizeWu.js
  290721  pinyin_to_script-DzUio84d.js
Enter fullscreen mode Exit fullscreen mode

A 69 MB chunk called Exploration. That is a real problem, but not the one I was hunting, because those filenames do not exist in dist/ at all. dist/assets/ contains exactly one JavaScript file:

83249461  dist/assets/index-RqoFan-G.js
Enter fullscreen mode Exit fullscreen mode

Different build. Not "heavy images shipped next to the app" — a different bundle with a different code-splitting shape. The old one had a 369 KB entry plus a lazily loaded 69.2 MB chunk. The current one is a single 83.2 MB entry.

Diffing the two directories

Two sorted file lists and comm are enough to make the drift undeniable:

(cd dist && find . -type f | sort) > /tmp/web.txt
(cd android/app/src/main/assets/public && find . -type f | sort) > /tmp/native.txt

comm -23 /tmp/native.txt /tmp/web.txt | wc -l   # in the app, not in the build
comm -13 /tmp/native.txt /tmp/web.txt | wc -l   # in the build, not in the app
Enter fullscreen mode Exit fullscreen mode
780   # in the app, not in the build
769   # in the build, not in the app
Enter fullscreen mode Exit fullscreen mode

Out of 1,386 files in the build, only 617 were present in the app. Calling the rest "extra weight" is generous — the two directories were mostly unrelated.

The orphans accounted for 625.3 MB of 636.2 MB, i.e. 98.3% of the payload:

extension files size
.jpg 694 500.5 MB
.js 12 83.1 MB
.png 73 41.7 MB
.css 1 ~0

Then the mapping became obvious. Every orphan had a same-named sibling in the new build with a different extension:

  • portraits/ — 292 .jpg → 292 .webp, identical basenames, identical subtrees
  • soul_trail/ — 379 .jpg → 379 .webp
  • calligraphy/ — 23 .jpg → 23 .webp
  • beasts/ — 73 .png → 73 .webp

The current dist/ has 767 .webp, 597 .svg, 18 .png, and zero .jpg. It is one image-format migration: JPEG/PNG → WebP. And it was a good migration — the 292 portraits alone went from 434.7 MB to 31.8 MB, same 292 images, 13.7× smaller.

So the real finding is not "my app is bloated". It is: I had already fixed the bloat, and the fix never reached the binary.

Why the APK still had the old files

Timestamps settle it:

app-release.apk                        Sep  8 19:49:19
android/app/src/main/assets/public     Sep  8 19:48:15
dist/                                  Sep 23 15:24:46
Enter fullscreen mode Exit fullscreen mode

The native copy is 64 seconds older than the APK it produced, and 15 days older than the build it was supposed to represent.

This is not a Capacitor bug, and I checked the source instead of assuming. In @capacitor/cli 8.5.0, copyWebDir is:

await remove(nativeAbsDir);
return copy(webAbsDir, nativeAbsDir);
Enter fullscreen mode Exit fullscreen mode

It wipes the destination and copies fresh. npx cap copy is a genuine sync, not a merge — so no stale-file accumulation is possible if you run it. The failure was mine: after the WebP migration I ran ./gradlew assembleRelease directly, because it is faster and because the app compiled. Gradle packaged whatever snapshot was sitting in assets/public, and nothing in the toolchain had an opinion about it.

That is the mental model worth internalizing. webDir: 'dist' does not create a link. It creates a copy, once, at the moment you invoke the command. After that, the native folder is a vendored dependency on your own past build — no version pin, no lockfile, no warning.

The three-second check

Hashed filenames are already a build fingerprint. You do not need a new tool to compare them:

unzip -p app-release.apk assets/public/index.html | grep -o '/assets/[^"]*'
grep -o '/assets/[^"]*' dist/index.html
Enter fullscreen mode Exit fullscreen mode
in apk : /assets/index-ChvbOeYn.css  /assets/index-DjfizeWu.js
on disk: /assets/index-FYxpahNp.css  /assets/index-RqoFan-G.js
Enter fullscreen mode Exit fullscreen mode

If those differ, the APK is not the app you have been testing in the browser. In my case it was not even close: I had been testing a single 83.2 MB entry bundle while signing a build that lazily loaded a 69.2 MB chunk. Same codebase, two different loading behaviours, and the one I validated was the one that never shipped.

Why this stays invisible for weeks

  • Stale builds do not crash. An old bundle is a valid bundle. The app launches, renders, and behaves like a slightly older version of itself.
  • Every local check reads the wrong directory. vite dev, vite preview, a browser tab — all of them read source or dist/. assets/public is only ever read by the packaged app.
  • Totals hide it; file lists don't. "636.2 MB vs 182.7 MB" reads as images are heavy. The correct reading was almost nothing in there is current.
  • Nobody diffs size between releases. Size is the one metric with no assertion attached to it, so it gets looked at only after someone complains about the download.

The guard

verify-web-assets.mjs — no dependencies, ~100 lines. It walks both directories into path → size maps, reports missing files, orphans (grouped by extension and summed), same-name-different-size files, and optionally pulls assets/public/index.html out of the APK to compare hashed asset references. Exit code 1 on any drift, so it can sit in front of assembleRelease.

Real output from my project:

web    1386 files, 182.7 MB
native 1397 files, 636.2 MB

MISSING in native: 769

ORPHANS in native: 780 files, 625.3 MB
  .jpg   500.5 MB
  .js     83.1 MB
  .png    41.7 MB
  .css     0.0 MB

APK CONTAINS A DIFFERENT BUILD
  in apk : index-ChvbOeYn.css, index-DjfizeWu.js
  on disk: index-FYxpahNp.css, index-RqoFan-G.js

RESULT: out of sync — run `npx cap copy` and rebuild.
Enter fullscreen mode Exit fullscreen mode

The core is not clever:

const walk = (root, base = root, out = new Map()) => {
  for (const entry of readdirSync(root, { withFileTypes: true })) {
    const full = join(root, entry.name);
    if (entry.isDirectory()) walk(full, base, out);
    else if (entry.isFile()) out.set(relative(base, full), statSync(full).size);
  }
  return out;
};

const orphans = [...native.keys()].filter((k) => !web.has(k));
Enter fullscreen mode Exit fullscreen mode

I also ran it against dist vs dist to be sure the "in sync" path is not a false-positive machine: 1386 / 1386, RESULT: in sync, exit 0. A guard that always fails is not a guard.

What I changed in the build process

  1. The native folder is a build artifact, not source. Nothing in it deserves trust, including its timestamps.
  2. cap copy and gradlew never get separated. One line, so half of it cannot be skipped: npx cap sync android && ./gradlew assembleRelease.
  3. Fingerprint the artifact, not the folder. The index.html inside the APK is the cheapest proof of what was actually shipped.
  4. Compare file lists before comparing totals. Totals produce a plausible story ("images") that can be completely wrong.
  5. Gate the signing step. The check is a directory walk; it costs less than typing the keystore password.

None of this is Capacitor-specific in spirit. Any tool that vendors your web output into a native shell — Cordova, Electron, Tauri, a Docker image copied by hand into a server folder — has the same silent-drift failure mode: the copy is a snapshot, the snapshot has no version, and the build system will happily package a snapshot from last month.

The 585 MB APK was not a compression puzzle. It was a fifteen-day-old decision that nobody had re-run.


I build NameSoul, an app that gives you a Chinese name and the historical life behind it, in ten languages. It is one bundled web build inside a native shell, and it now gets diffed against itself before every signing.

Top comments (0)