The situation
You are a mobile lead on a React Native product that just shipped version 2.4 with Expo Router 2.1.0. The release added a “share‑to‑app” feature that opens a specific article via a deep link (myapp://article/123).
Two days after the rollout, the analytics dashboard shows a spike in “App opened via URL” events that never transition to a screen – the app stays on the splash screen for several seconds and then crashes. Users on Android report “App stopped” in the system dialog; iOS users see a blank white screen after tapping the link in Mail.
The incident forces the team to roll back the release, delaying the next sprint by a day and costing roughly 5 % of the weekly active users a poor first‑impression experience.
The documentation for Expo Router’s linking is thorough, but it does not explain why the same code works in a dev client (expo start) and fails in an OTA‑updated production build under load.
Why it happens
Static route generation vs runtime path resolution
Expo Router builds a static route manifest at bundle time. In development the manifest is regenerated on every reload, so a newly added file‑system route (app/article/[id].tsx) is always present. In a production OTA update the manifest is baked into the JS bundle. If a deep link contains a segment that the manifest does not include (e.g., a new dynamic segment added after the last OTA), the router falls back to the+not-found.tsxroute. When that file is missing, the navigation stack ends up with an empty route list, causing the native bridge to throwInvariant Violation: Navigation container is not ready.Android intent‑filter ordering
Expo’sapp.jsongenerates an<intent-filter>for each scheme defined inexpo.scheme. When multiple schemes exist (e.g.,myappandmyapp-dev), Android merges them into a single<data>element. The order of<data>elements is not guaranteed; on some devices the system picks the first matching scheme, which may be the development scheme that does not correspond to a real activity. The result is anActivityNotFoundExceptionlogged asjava.lang.IllegalStateException: No activity found to handle Intent { act=android.intent.action.VIEW dat=myapp://article/123 ... }.iOS universal‑link association file latency
Forhttps://myapp.com/article/123links, Apple requires anapple-app-site-association(AASA) file served over HTTPS with aContent-Type: application/json. In production the CDN may cache an old version of the file after a deployment that adds a new path pattern. The device therefore treats the link as a regular web URL, opening Safari instead of the app. Users think the app “doesn’t open” while the OS silently redirects.Bundle‑size‑induced cold‑start delay
Expo Router lazily loads screen components when a route is first resolved. A deep link that points to a screen not yet in memory triggers a dynamicimport(). On a cold start with a 70 MB bundle, the JS thread can be blocked for 800 ms on a mid‑range Android device. If the user taps the link repeatedly (common when sharing), the OS may kill the process for “ANR” (Application Not Responding), yielding the “App stopped” dialog.Missing
linkingconfig inapp.jsonfor OTA
Theexpo.linkingfield is optional in the dev client because the Metro bundler injects the scheme at runtime. In a production build the native manifest is generated once. Ifexpo.linkingis omitted, the native side registers no scheme, so the OS falls back to the generic “Open with” chooser, which on Android 12+ may be hidden, giving the impression that the link is dead.Query‑parameter encoding edge cases
Expo Router parses the URL withURL.parse. When a query string contains characters that are not percent‑encoded (e.g., spaces in a title), the parser throwsTypeError: Failed to parse URL. This only surfaces when real users share URLs generated by the web version, which does not always encode spaces.
Each of these failure modes is silent in the dev client because the environment either regenerates the manifest on the fly, uses a fast dev server, or runs on a machine with ample resources. Under production load, the combination of static manifests, native intent handling, and bundle size reveals the bugs.
The fix, step by step
Prerequisite – The project is on Expo SDK 48, Expo Router 2.1.0, and uses EAS Build for production bundles. Ensure you have the latest CLI (
npm i -g eas-cli) and that the repository is on themainbranch.
1. Freeze the route manifest for OTA updates
- Open
app/router.tsand add the explicitexport const unstable_routeManifest = true;flag.
// app/router.ts
export const unstable_routeManifest = true;
Why: This tells Expo Router to embed the current manifest into the bundle and to reject unknown routes at runtime instead of silently falling back.
- Run a test build:
eas build --profile preview --platform all
- Install the preview on a device and open a deep link that points to a new route not yet in the manifest.
Confirm: The app should display the
+not-found.tsxscreen with a clear “Route not found” message instead of crashing.
2. Consolidate Android intent filters
- In
app.json(orapp.config.js), define a single scheme and avoid mixing dev and prod schemes.
{
"expo": {
"scheme": "myapp",
"android": {
"intentFilters": [
{
"action": "VIEW",
"data": [
{
"scheme": "myapp",
"host": "*"
}
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}
- Remove any
expo.schemeentries that are only used for development. - Rebuild the Android binary:
eas build --profile production --platform android
- Verify with
adb logcatthat the intent is received:
adb logcat | grep -i "myapp"
You should see a line like Intent received: myapp://article/123. No ActivityNotFoundException should appear.
3. Ensure the AASA file is up‑to‑date
- Generate the AASA file using the Expo CLI:
expo export:ios --public-url https://myapp.com
The CLI writes apple-app-site-association to dist/ios.
Deploy the file to the CDN with a cache‑busting query string (e.g.,
https://cdn.myapp.com/apple-app-site-association?v=20231018).In the CDN configuration, set
Cache-Control: max-age=60, must-revalidate. This forces devices to re‑fetch the file at least once per minute after a deployment.Test on an iOS device:
xcrun simctl openurl booted "https://myapp.com/article/123"
Confirm: The app opens directly; Safari is not launched.
4. Pre‑load critical screens to avoid cold‑start stalls
Identify the screens most often opened via deep link (e.g.,
ArticleScreen).In
app/_layout.tsx, add auseEffectthat prefetches the module on app start:
import { useEffect } from 'react';
import { router } from 'expo-router';
export default function Layout() {
useEffect(() => {
// Pre‑load the article screen bundle
router.prefetch('/article/[id]');
}, []);
return <Slot />;
}
- Build a production bundle and measure start‑up time on a mid‑range device (e.g., Pixel 5).
adb shell am start -W -n com.myapp/.MainActivity
Look for TotalTime in the output; it should be < 1 s after the change (previously ~1.8 s on the same device).
5. Add the missing linking field
- Edit
app.jsonto include:
{
"expo": {
"scheme": "myapp",
"linking": {
"scheme": "myapp",
"url": "myapp://"
}
}
}
Re‑run
eas buildfor both platforms.Verify on Android with
adb shell am start -a android.intent.action.VIEW -d "myapp://article/123"– the app should launch without the “Open with” chooser.
6. Guard against malformed query strings
- Wrap the router’s
useSearchParamsin a try/catch that normalises the URL:
import { useSearchParams } from 'expo-router';
export function useSafeSearchParams() {
try {
return useSearchParams();
} catch (e) {
// Fallback: manually decode the query part
const raw = new URL(window.location.href).search;
return Object.fromEntries(new URLSearchParams(raw));
}
}
Replace all
useSearchParamscalls withuseSafeSearchParams.Run a quick integration test with a deliberately malformed link:
xcrun simctl openurl booted "myapp://article/123?title=Hello World"
Confirm: No TypeError appears; the title param is received as "Hello World".
What it costs, and when not to do it
| Fix | Cost (time / bundle) | When to skip |
|---|---|---|
Freeze manifest (unstable_routeManifest) |
+5 min CI time, negligible runtime cost | If you never add routes after a release (rare) |
| Single Android scheme | No runtime cost; requires coordination across dev and CI | If you maintain separate dev and prod builds on the same device (not recommended) |
| AASA cache‑busting | Small CDN configuration effort; adds a query string to the URL (harmless) | If you only support custom‑scheme links on iOS |
| Prefetch critical screens | Increases JS bundle size by ~30 KB per prefetched module | If the app is already > 150 MB and cold‑start time is acceptable |
Explicit linking field |
Zero runtime cost; adds a few lines to config | None – always advisable for production |
| Safe query‑param wrapper | Negligible runtime overhead; adds a tiny utility file | If you guarantee all URLs are generated by your own client (unlikely) |
The most common trade‑off is bundle size vs cold‑start latency. Prefetching reduces the perceived delay for deep links but adds to the initial download. On a 2G network the extra 30 KB can add ~1 s to the first‑time download, so weigh that against the user‑experience gain for the deep‑link path.
Do not apply the manifest freeze if you rely on runtime‑generated routes (e.g., a server‑driven navigation tree). In that case you must instead implement a custom fallback that fetches the missing route definition from the server before navigating.
At your level
| Role | Take‑away | One concrete next step |
|---|---|---|
| Student / first‑year engineer | Deep links are more than a URL string – they interact with native intent filters and a static route manifest. | Add unstable_routeManifest = true to a sandbox project and observe the “route not found” screen. |
| Working engineer (feature ship) | A missing linking config or stale AASA file can silently break production traffic. |
Verify that app.json contains both scheme and linking, then run eas build --profile preview and test a link on a real device. |
| Senior / staff engineer | The biggest production risk is the mismatch between the static manifest baked into the OTA bundle and the dynamic routes added after release. | Introduce a CI check that fails if a new file‑system route is added without bumping the version and rebuilding the OTA bundle. |
| Lead / director | Deep‑link reliability directly impacts acquisition metrics; the cost of a failed link is a lost user and a rollback sprint. | Allocate a 2‑day internal “link‑stress” sprint that runs automated deep‑link tests on a matrix of Android/iOS devices before every major release. |
In the interview
Question (as asked by a senior engineering manager):
“Explain how you would ensure that deep links built with Expo Router work reliably in production, especially under load. What hidden failure modes have you seen, and how would you detect them before a release?”
Weak answer – repeats documentation: “You just add the scheme to app.json and test on the simulator.”
Why it’s weak: It shows no awareness of production‑only issues, no mention of static manifest, intent‑filter ordering, or bundle‑size latency. The candidate never demonstrates a verification strategy.
Strong answer – mentions the three hidden failure modes (static manifest
Amit Chakraborty is a founding engineer and senior architect — React Native, AI/RAG systems and production architecture. Portfolio: www.amitchakraborty.dev · LinkedIn · GitHub. Open to senior and founding engineering roles, remote worldwide.
Top comments (0)