●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Expo SDK 58 is out of beta as of 10/15 (beta since 09/15). Check your dependencies before upgrading●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
It Ran Fine in Debug, but the Release Build Died on Launch with a Resource Not Found. A File I Looked Up by Name Had Been Shrunk Away
A release-only crash at launch, caused by images and sounds looked up by a name string being removed by resource shrinking. A keep.xml written at prebuild, plus a post-build check that confirms it worked.
Debug launched cleanly every time. The moment I switched to a release build, the Android version of one of my wallpaper apps stopped right after launch. The log held a single line, roughly "not found".
The code was identical to what had worked in debug. Only the build type differed. I suspected signing and obfuscation first, and dug in the wrong places for a while.
"It worked in debug" is not evidence that it works in release. This crash was a textbook case. As an indie developer I hit this symptom in a real app; this note rewrites it for an Expo project, covering how to tell the cause apart, how to fix it, and a check that keeps it from coming back.
Decide what to suspect from the symptom
A release-only crash usually falls into one of two groups: code that was shrunk away, or resources (images, sounds, XML) that were shrunk away. The message in the crash log tells them apart.
What the log says
Suspect
First move
ClassNotFoundException, NoSuchMethodError
Code shrinking (R8)
Look the name up in mapping.txt and add a keep rule
Resource shrinking only runs in release builds, and only when code shrinking is on. Debug builds skip it, so resources that look unused are simply left in. In other words, debug behavior has never passed through the shrinking step at all.
The shrinker statically looks for referenced resources in your code. A constant reference such as R.raw.notify_chime is found. What it cannot see is a name assembled at runtime and passed in as a string.
The official documentation says that a format string such as img_%1d makes the shrinker treat every resource starting with img_ as used. The flip side is that a name received from a server response or a config file leaves no string constant in the code to hint at it, so nothing protects it.
The crash in my app was the same kind. A resource that existed in debug was judged unnecessary and removed in release, and the app stopped at launch with a "not found". The fix was to protect the resources I did not want removed with an explicit keep.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can now list every image and sound in your Expo project that is looked up by a name string, and check before shipping whether any of them sit on the side that release builds remove
✦You can now write a keep.xml from a config plugin that survives every prebuild, so you keep resource shrinking on and protect only the resources you name
✦You can now catch a failure that never reproduces in debug with a single post-build check, instead of launching a release build on one device after another
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Before fixing anything, list every place in your project that does this. Even a JS-only project can have libraries that fetch a notification sound or icon by name string.
# Native code (Kotlin / Java) that turns a name into an IDgrep -rn "getIdentifier" android/ modules/ --include=*.kt --include=*.java 2>/dev/null# Expo config that passes a resource name as a string (notification sounds, icons, etc.)grep -n -E '"(sound|icon|smallIcon)"' app.json app.config.js 2>/dev/null
For each hit, ask whether the name appears as a string constant in the code. If it does not, that resource can be shrunk. The names you collect here become the resources you protect in the next step.
Keep shrinking on, and protect only what you name
The easiest fix is to turn shrinkResources off, but your app size goes back up, so I did not take that route. Instead, name the resources to protect in res/raw/keep.xml.
In a managed Expo setup, however, the android/ folder is regenerated on every prebuild, so a hand-placed keep.xml disappears the next time. Let a config plugin write it instead.
// plugins/with-keep-resources.js// Writes a keep.xml on every prebuild so that resources looked up by name are excluded from resource shrinkingconst fs = require("fs");const path = require("path");const { withDangerousMod } = require("expo/config-plugins");// Accept only entries shaped like "@raw/notify_chime" or "@drawable/thumb_*"const ENTRY = /^@[a-z]+\/[A-Za-z0-9_*]+$/;function buildKeepXml(keep) { const bad = keep.filter((k) => !ENTRY.test(k)); if (bad.length > 0) { throw new Error(`with-keep-resources: malformed entries: ${bad.join(", ")}`); } return [ '<?xml version="1.0" encoding="utf-8"?>', '<resources xmlns:tools="http://schemas.android.com/tools"', ` tools:keep="${keep.join(",")}" />`, "", ].join("\n");}const withKeepResources = (config, { keep = [] } = {}) => withDangerousMod(config, [ "android", async (cfg) => { if (keep.length === 0) return cfg; // write nothing when the list is empty const dir = path.join(cfg.modRequest.platformProjectRoot, "app/src/main/res/raw"); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, "keep.xml"), buildKeepXml(keep)); return cfg; }, ]);module.exports = withKeepResources;module.exports.buildKeepXml = buildKeepXml; // for local testing
I added the format check because I wanted to avoid a mistake that silently stops working. An entry like raw/x, with the leading @ forgotten, is not a syntax error; it is simply ignored. Stopping at prebuild is gentler than finding out from a crash.
Resource shrinking only works while code shrinking is on. The name of the property that turns code shrinking on differs between SDK versions, so please check the documentation for the version you use.
After the build, check by machine that the keep took effect
Writing keep.xml does not tell you whether it worked. When shrinking runs, Gradle writes a resources.txt; after every build I check that none of the resources I want kept is listed there as unused. According to the official documentation, this file is placed under build/outputs/mapping/release/ and holds details about which resources were used and which were removed.
#!/usr/bin/env python3"""Read the resources.txt left by a release build and confirm that none of the resources to protect were shrunk.Usage: python3 scripts/check_kept_resources.py <resources.txt> raw/notify_chime drawable/thumb_ A name ending in _ is a prefix match. The format of resources.txt changes between AGP versions, so this only looks at lines that contain the resource path and one of: unused / removing / skipped."""import reimport sysfrom pathlib import PathMARKERS = ("unused", "removing", "skipped")def pattern_for(spec: str) -> re.Pattern: kind, _, entry = spec.partition("/") tail = re.escape(entry) + (r"[A-Za-z0-9_]*" if entry.endswith("_") else r"(\.[A-Za-z0-9]+)?(?![A-Za-z0-9_])") # matches both res/raw/... and res/drawable-xxhdpi/... return re.compile(rf"res/{re.escape(kind)}[^/\s]*/{tail}")def find_removed(text: str, wanted: list[str]) -> list[str]: hits = [] lines = [ln for ln in text.splitlines() if any(m in ln.lower() for m in MARKERS)] for spec in wanted: pat = pattern_for(spec) for ln in lines: if pat.search(ln): hits.append(f"{spec}: {ln.strip()}") break return hitsdef main(argv: list[str]) -> int: if len(argv) < 3: print(__doc__) return 2 path = Path(argv[1]) if not path.is_file(): print(f"FAIL: {path} not found. Did you build a release with shrinkResources enabled?") return 2 hits = find_removed(path.read_text(encoding="utf-8", errors="replace"), argv[2:]) if hits: print("FAIL: resources you wanted to keep are being shrunk") for h in hits: print(" -", h) return 1 print(f"OK: {len(argv) - 2} entries are not listed as shrunk") return 0if __name__ == "__main__": sys.exit(main(sys.argv))
I checked this script locally against a sample file that contains lines saying a resource was skipped as unused. Given the name of a resource to protect, it returns exit code 1 and the matching line; if the name does not appear it returns 0, and it returns 2 when the file is missing. A prefix entry (drawable/thumb_) also matched through density folders such as drawable-xxhdpi.
Using it takes one added line after the build.
cd android && ./gradlew :app:assembleRelease && cd ..python3 scripts/check_kept_resources.py \ android/app/build/outputs/mapping/release/resources.txt \ raw/notify_chime drawable/thumb_
Three things that were not what I expected
First, adding keep.xml before adding the check made it easy to stop verifying. Having added it and having it work are separate facts. Run the check right after you add an entry.
Second, names that cannot be listed one by one. When a screen assembles names as it goes, the number of entries to name grows. Naming by prefix such as thumb_* and keeping the resource naming consistent made this easier later. The naming rule becomes the boundary of what you protect.
Third, how the size moves afterward. Each protected resource makes the AAB larger. If that difference bothers you, I think the better path is to reduce the places that look things up by name rather than to add more entries.
Confirm on a real device
Even when the check passes, finish by launching the release build once on a device. Use a different device from the one you debug on, reinstall the app, and walk from a cold start to the screen that looks things up by name.
Build the release and run the check
Uninstall once on the device and install the release build again
Open the screens that look things up by name, starting from the first launch
If it crashes, look up the "not found" name in resources.txt first
The code in this note is verified locally up to the keep.xml string generation in the plugin and the decision logic of the check script. The format of a real resources.txt from a full Gradle build may differ by AGP version, so please adjust the words in MARKERS to match the output in your environment.
What to do first tomorrow
Run the two searches above once in your own project. If you find a place that looks up a resource by name string, move that name into the keep list and add the post-build check as one extra line.
A failure that only happens in release can only be caught by a check placed inside the release process. Knowing once that some places are out of reach of any amount of debug testing shortens the search considerably.
If this saves you one afternoon of looking in the wrong place, I will be glad.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.