eas-preflight: Catching Expo Bundle Size Regressions Before They Ship
eas-preflight: Catching Expo Bundle Size Regressions Before They Ship
Dependency drift has good tooling. expo-doctor catches version mismatches before they cause a
build failure. Config drift has EAS's own build checks. But app size doesn't have anything: a PR
quietly pulls in a large font, a chart library, a couple of unoptimized images, and nobody notices
until users start complaining about install size, months later, when it's much harder to find the
commit responsible.
eas-preflight is a free, open source GitHub Action that closes that gap. It measures your Expo app's JS bundle size on every PR, compares it against the base branch, and posts a comment with the diff, the same way a test coverage bot would. You can optionally set a threshold that fails the check if a PR increases size beyond what you're comfortable with.
How It Works
The core loop is three steps:
- Export the JS bundle for the PR's head commit:
expo export --platform iosand--platform android, neverweborall, since web bundle bytes don't count toward what actually ships in a native binary, and Expo Router's web static-render step has its own unrelated failure modes. - Check out the base branch into a temporary
git worktree, symlinknode_modulesand any gitignored.env*files into it, and run the same export there. - Diff the two, and post (or update) a single PR comment via a hidden HTML marker, so pushing more commits updates the same comment instead of spamming the thread.
- uses: CodeEnthusiast09/eas-preflight@v1
with:
max-increase-percent: '10'
That's the entire setup. No config file, no separate dashboard, just a few lines of YAML.
Three Bugs I Only Found by Testing for Real
The whole point of this tool is to be a trustworthy CI gate, so a wrong or flaky size check is worse than not having one at all. Code review alone didn't catch any of the following. Running the pipeline against a real, non-trivial Expo app, and eventually against a real shallow CI checkout, did.
1. Gitignored env files crash the base-ref export. .env* files are gitignored, so a git
worktree checked out for the base branch doesn't have them. Any app that reads a required env var
at module load time (a Convex or Supabase URL, an API base URL) crashes immediately. Fixed by
symlinking .env* files alongside node_modules when setting up the worktree.
2. Metro's bundler cache leaks stale transforms between exports. After fixing the env-file
issue, the size comparison started flaking between runs with no code change at all: one run
reported a 197% increase (correct, matched a manual export), the next reported 0.4% (wrong, the
base size had nearly caught up to head). The cause was Metro reusing cached transforms from the
previous export when measuring two different checkouts back to back. Adding --clear to every
expo export call fixed it, confirmed deterministic across several repeated runs afterward.
3. The base ref doesn't actually exist where I assumed it did. This was the one that would
have broken the action on every single real CI run, and it stayed invisible the entire time I was
testing against my own machine. actions/checkout never creates a local branch for the base ref,
even with fetch-depth: 0. It only creates a remote-tracking ref:
refs/remotes/origin/main, never refs/heads/main. And git worktree add --detach does not fall
back to a remote-tracking branch the way a plain git checkout <branch> does. So
git worktree add --detach <dir> main worked perfectly on every local clone I tested, because my
local clones already had a local main branch, and would have failed with fatal: invalid reference: main on every actual PR. I only caught it by deliberately reproducing what
actions/checkout does: a shallow, single-commit, detached-HEAD clone, with no local branches at
all. The fix tries the bare ref first, then falls back to origin/<ref>.
Ignore Patterns and Monorepo Support
Two smaller pieces rounded out v1:
ignore-patterns: comma or newline separated globs (e.g.assets/**) to exclude specific paths from the size diff, matched against each platform's own export root so you never need to know the action's internalios/androidoutput layout.working-directory: points at the Expo project inside a monorepo. The tricky part here is thatgit worktree addchecks out the entire repository, not just your subdirectory, so the base ref's project actually lands at<worktree>/<subpath>, not at the worktree root.
Lessons Learned
- Local-clone testing systematically hides ref-resolution bugs. Anything involving git state needs to be tested against the actual shape of a real CI checkout (shallow, detached, single branch), not just a full local clone that happens to have every branch already.
- An intermittently-wrong result is worse than a consistently-broken one. A check that's visibly broken gets fixed immediately. A check that's silently wrong sometimes erodes trust slowly, and can mask a real regression as negligible.
- Test against a real app, not a synthetic demo. Every one of these three bugs only surfaced because of something specific to a real Expo project: a real env var read at load time, a real Metro cache, a real shallow CI clone. A synthetic "hello world" Expo app wouldn't have triggered any of them.
The source is on GitHub, MIT licensed.