Skip to main content

Android CI Memory And Local Bundle Checks

Last updated: 2026-07-08 EC2 instance: t3a.large — 7842 MiB usable RAM

Current Safe Allocation

During bundleRelease, the Gradle JVM and the Metro bundler (Node.js subprocess) run concurrently. Both need bounded heap or the EC2 instance OOMs.

ProcessHeap capWhere it is set
Node.js / Metro bundler--max-old-space-size=3072 (3 GiB)NODE_OPTIONS in the job-level env: block of mobile-android-release.yml
Gradle JVM-Xmx2048m (2 GiB)GRADLE_MB=2048 in the "Tune build memory limits" step
Gradle Metaspace-XX:MaxMetaspaceSize=512m"Configure Gradle" step (gradle.properties)
Peak total5632 MiBleaves ~2.2 GiB for OS + runner process

Why This Split

Metro does all the heavy JS bundling. The app has hundreds of API route files — peak heap during a full bundle is > 2 GiB. Gradle itself only orchestrates (spawns Metro for JS, CMake for native), so 2 GiB is plenty for the Gradle daemon.

NODE_OPTIONS cannot be set via $GITHUB_ENV. GitHub Actions blocks it for security. It must live in the job-level env: block; a step cannot override it at runtime.

Failure History

VersionSymptomRoot causeFix applied
≤ v1.8.125Build canceled mid-run: "runner has received a shutdown signal"stop-runner called /repos/.../actions/runners (requires manage_runners:org — GITHUB_TOKEN in org repos returns 403). Error JSON was concat'd with the || echo 0 fallback → BUSY=<json>0 (non-integer). Bash -gt comparison errored silently → treated as "not busy" → EC2 stopped while next build was mid-GradleSwitched to workflow runs API (/actions/runs), accessible with actions: read. Added safe default: skip stop on any non-integer response
v1.8.129SIGTERM after ~7 s of Gradle ("The operation was canceled")Same stop-runner 403 bug — one run's stop-runner killed the next queued run's Gradle after EC2 shutdown propagated SIGTERMSame fix as above
v1.8.129 (earlier attempt)SIGTERM from OS OOM killerNODE_OPTIONS=3584m + Gradle 4096m = 7680 MiB; left no room for OS → kernel killed Gradle with SIGTERMReduced both values
v1.8.131FATAL ERROR: JavaScript heap out of memory at 2036/2048 MiBOver-correction left Node at only 2048m while Gradle got 3072m; Metro peaked above 2 GiB during bundleSwap: Node=3072m, Gradle=2048m (same 5632 MiB total)
v1.8.170Unable to resolve module ... @expo/metro-config/build/async-require.js from lib/supabase.tsAndroid Metro rewrote native-reachable import() to Expo's async-require helper, but that helper is not present in the release bundle graphReplaced native lazy import() with lazy require()
v1.8.171Same async-require.js failure from components/CrystalForgeBackground.tsxA web-only Three.js background was imported by a cross-platform route, so Android parsed its web dynamic importsAdded native stubs for web-only components and removed native-reachable auth dynamic imports
v1.8.172Failed to get the SHA-1 for ... node_modules/.pnpm/metro-runtime.../require.jsMetro resolved a pnpm virtual-store file, then the broad nested node_modules blocklist excluded that same file from the Haste graphNarrowed the blocklist so real nested dependency trees are skipped but node_modules/.pnpm/... stays hashable
v1.8.174+Local web dev: FATAL ERROR: Ineffective mark-compacts near heap limit at ~12 GiB + Error: Premature close on every bundle requestDev scripts defaulted to --max-old-space-size=16384 on a 16 GiB machine — V8 delays GC until 88% × 16 GiB = ~14.4 GiB, but the OS OOM-kills the process at ~12 GiB, leaving no headroom for compaction. "Premature close" = browser clients timing out during 5+ s GC pausesChanged default in apps/mobile-app/package.json to --max-old-space-size=12288 (12 GiB). V8 now compacts at ~10.8 GiB, well within the machine's physical limit. Override with NODE_MAX_OLD_SPACE_SIZE=NNNN if on a different machine.

Local Android Bundle Preflight

Run this before dispatching a Play build when the previous failure was in :app:createBundleReleaseJsAndAssets. It validates the Android Metro bundle and Expo Router API export without requiring a local Play upload or signing secrets.

cd apps/mobile-app
rm -rf /tmp/hashpass-android-assets /tmp/hashpass-index.android.bundle /tmp/hashpass-index.android.map

NODE_ENV=production \
EXPO_NO_METRO_WORKSPACE_ROOT=1 \
NODE_OPTIONS="--max-old-space-size=12288" \
pnpm exec expo export:embed \
--platform android \
--dev false \
--minify false \
--entry-file index.js \
--bundle-output /tmp/hashpass-index.android.bundle \
--sourcemap-output /tmp/hashpass-index.android.map \
--assets-dest /tmp/hashpass-android-assets \
--max-workers 2 \
--reset-cache

Expected success output includes:

Android Bundled ... index.js
Done writing bundle output
Done writing sourcemap output

If this command fails with @expo/metro-config/build/async-require.js, search native-reachable code for import(. Android cannot safely parse those dynamic imports in app code. Prefer one of these fixes:

  • Move web-only code behind a .web.tsx file and add a .native.tsx stub.
  • Replace native lazy dynamic imports with lazy require() guarded by Platform.OS !== 'web'.
  • Keep dynamic import() only in server routes or true web-only files.

This preflight is not a full Fastlane release. A full local bundleRelease also requires a JDK with javac, Android SDK, Ruby/Bundler/Fastlane, and signing credentials.

Diagnosing Future Failures

Metro OOM — look for this in Gradle task output:

FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory
> Task :app:createBundleReleaseJsAndAssets FAILED

Fix: raise NODE_OPTIONS, lower GRADLE_MB by the same amount.

Gradle OOM — look for a heap dump file in apps/mobile-app/android/ and this in logs:

java.lang.OutOfMemoryError: Java heap space

Fix: raise GRADLE_MB, lower NODE_OPTIONS by the same amount.

SIGTERM / "runner has received a shutdown signal" — the EC2 instance was stopped while a build was running. Check the stop-runner step log for a 403 error on gh api. The current fix uses the workflow runs API; if that also returns an error, the script defaults to exit 0 (skip stop, rely on the 600 s idle watchdog instead).

Metro async-require.js resolution failure — look for this in createBundleReleaseJsAndAssets:

Error: Unable to resolve module ... @expo/metro-config/build/async-require.js

Fix: run the local Android bundle preflight above, then remove dynamic import() from the source file named in the error.

Metro SHA-1 failure for node_modules/.pnpm — look for this in createBundleReleaseJsAndAssets:

Error: Failed to get the SHA-1 for:
.../node_modules/.pnpm/metro-runtime.../node_modules/metro-runtime/src/polyfills/require.js

Fix: check apps/mobile-app/metro.config.js. watchFolders must include the workspace dependency root, and blockList must not match pnpm virtual-store paths under node_modules/.pnpm/.

Scaling Up

If bundles keep growing and 3072m is no longer enough for Metro, upgrade to t3a.xlarge (16 GiB) and update the threshold in "Tune build memory limits":

# Current (8 GiB):
if [ "$TOTAL_MB" -le 9216 ]; then
GRADLE_MB=2048
GRADLE_WORKERS=2

# After upgrade to t3a.xlarge (16 GiB):
if [ "$TOTAL_MB" -le 9216 ]; then
GRADLE_MB=2048
GRADLE_WORKERS=2
elif [ "$TOTAL_MB" -le 18432 ]; then
GRADLE_MB=4096
GRADLE_WORKERS=4

And update NODE_OPTIONS to --max-old-space-size=6144 in the job-level env: block. Keep the combined peak under 80 % of total RAM.

Key Files

  • .github/workflows/mobile-android-release.ymlNODE_OPTIONS (job env: block), "Tune build memory limits" step, "Configure Gradle" step
  • apps/mobile-app/metro.config.js — Metro cache dir (METRO_CACHE_DIR env)
  • apps/mobile-app/components/*.native.tsx — native stubs for web-only components that would otherwise pull browser/Three.js code into Android