All posts

    eas-preflight: Catching Expo Bundle Size Regressions Before They Ship

    June 24, 20267 min read
    GitHub Actions
    Expo
    React Native
    DevTools
    CI/CD

    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:

    1. Export the JS bundle for the PR's head commit: expo export --platform ios and --platform android, never web or all, 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.
    2. Check out the base branch into a temporary git worktree, symlink node_modules and any gitignored .env* files into it, and run the same export there.
    3. 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 internal ios/android output layout.
    • working-directory: points at the Expo project inside a monorepo. The tricky part here is that git worktree add checks 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

    1. 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.
    2. 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.
    3. 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.