DEV Community

Cover image for Deep‑link crashes in Expo Router when the app hits real traffic
Amit chakraborty
Amit chakraborty

Posted on Originally published at amitchakraborty.dev

Deep‑link crashes in Expo Router when the app hits real traffic

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

  1. 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.tsx route. When that file is missing, the navigation stack ends up with an empty route list, causing the native bridge to throw Invariant Violation: Navigation container is not ready.

  2. Android intent‑filter ordering
    Expo’s app.json generates an <intent-filter> for each scheme defined in expo.scheme. When multiple schemes exist (e.g., myapp and myapp-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 an ActivityNotFoundException logged as java.lang.IllegalStateException: No activity found to handle Intent { act=android.intent.action.VIEW dat=myapp://article/123 ... }.

  3. iOS universal‑link association file latency
    For https://myapp.com/article/123 links, Apple requires an apple-app-site-association (AASA) file served over HTTPS with a Content-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.

  4. 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 dynamic import(). 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.

  5. Missing linking config in app.json for OTA
    The expo.linking field 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. If expo.linking is 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.

  6. Query‑parameter encoding edge cases
    Expo Router parses the URL with URL.parse. When a query string contains characters that are not percent‑encoded (e.g., spaces in a title), the parser throws TypeError: 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 the main branch.

1. Freeze the route manifest for OTA updates

  1. Open app/router.ts and add the explicit export const unstable_routeManifest = true; flag.
   // app/router.ts
   export const unstable_routeManifest = true;
Enter fullscreen mode Exit fullscreen mode

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.

  1. Run a test build:
   eas build --profile preview --platform all
Enter fullscreen mode Exit fullscreen mode
  1. 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.tsx screen with a clear “Route not found” message instead of crashing.

2. Consolidate Android intent filters

  1. In app.json (or app.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"]
           }
         ]
       }
     }
   }
Enter fullscreen mode Exit fullscreen mode
  1. Remove any expo.scheme entries that are only used for development.
  2. Rebuild the Android binary:
   eas build --profile production --platform android
Enter fullscreen mode Exit fullscreen mode
  1. Verify with adb logcat that the intent is received:
   adb logcat | grep -i "myapp"
Enter fullscreen mode Exit fullscreen mode

You should see a line like Intent received: myapp://article/123. No ActivityNotFoundException should appear.

3. Ensure the AASA file is up‑to‑date

  1. Generate the AASA file using the Expo CLI:
   expo export:ios --public-url https://myapp.com
Enter fullscreen mode Exit fullscreen mode

The CLI writes apple-app-site-association to dist/ios.

  1. Deploy the file to the CDN with a cache‑busting query string (e.g., https://cdn.myapp.com/apple-app-site-association?v=20231018).

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

  3. Test on an iOS device:

   xcrun simctl openurl booted "https://myapp.com/article/123"
Enter fullscreen mode Exit fullscreen mode

Confirm: The app opens directly; Safari is not launched.

4. Pre‑load critical screens to avoid cold‑start stalls

  1. Identify the screens most often opened via deep link (e.g., ArticleScreen).

  2. In app/_layout.tsx, add a useEffect that 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 />;
   }
Enter fullscreen mode Exit fullscreen mode
  1. 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
Enter fullscreen mode Exit fullscreen mode

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

  1. Edit app.json to include:
   {
     "expo": {
       "scheme": "myapp",
       "linking": {
         "scheme": "myapp",
         "url": "myapp://"
       }
     }
   }
Enter fullscreen mode Exit fullscreen mode
  1. Re‑run eas build for both platforms.

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

  1. Wrap the router’s useSearchParams in 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));
     }
   }
Enter fullscreen mode Exit fullscreen mode
  1. Replace all useSearchParams calls with useSafeSearchParams.

  2. Run a quick integration test with a deliberately malformed link:

   xcrun simctl openurl booted "myapp://article/123?title=Hello World"
Enter fullscreen mode Exit fullscreen mode

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)