When you run npx expo start --tunnel, Expo CLI starts an ngrok agent and serves your Metro bundler at a public exp.direct address, so a phone on any network can load your app. The agent comes from the @expo/ngrok package, which Expo CLI asks you to install the first time. It ships ngrok agent 2.3.41, it logs in with one authtoken that every Expo user shares, and Expo CLI gives it 10 seconds to connect before it prints ngrok tunnel took too long to connect.
That shared account is where most failures come from. In February 2026 it hit ngrok's limit of 5,000 simultaneous agent sessions, and --tunnel failed for many developers at once with Cannot read properties of undefined (reading 'body') (expo/expo#43335↗). An Expo maintainer replied in that thread that the feature is "neither a paid service (no service-level guarantees), nor meant as a permanent solution", and suggested a tunnel on your own account for regular use.
Developer @bidah put the day-to-day version on X↗:

I can't live without @LocalCanApp for React Native development. Expo tunnel? It gets regenerated every time you restart the server. Also, the tunnel gets dropped very often. It's inconsistent and not reliable.
The examples below use LocalCan, the tool I build, to give Expo Go a Public URL (also called a tunnel). The Expo and localcan http runs are from October 10, 2026 on Expo SDK 57. An AI assistant helped draft this post. The commands and numbers come from my own runs.
The Problem with Expo Tunnel
According to the Expo CLI source↗, the flag does four things:
- Expo CLI finds
@expo/ngrok, in the project or installed globally, and starts the ngrok agent it ships with - The agent logs in with Expo's shared authtoken and requests a hostname like
<random>-<user>-8081.exp.direct - Expo serves the app manifest and the JavaScript bundle through that hostname
- Expo Go loads the app from
exp://<random>-<user>-8081.exp.direct, and a development build loads the same host overhttps://
Here is a run on a blank SDK 57 app, with the QR code left out:
Starting Metro Bundler
Tunnel connected.
Tunnel ready.
› Scan the QR code above to open in Expo Go.
› Metro: exp://e-q0oqg-anonymous-8081.exp.direct
The tunnel was ready 2.6 seconds after the command started. The random part of the hostname is stored in .expo/settings.json, and "anonymous" stands in for the Expo username because I was not logged in.
Does the tunnel URL change on every restart?
Not by design. I stopped the server and started it again two seconds later, and it came back as e-q0oqg-anonymous-8081.exp.direct with the same urlRandomness value in .expo/settings.json. Expo CLI picks a new random part in two cases. The first is a missing .expo folder (a fresh clone, a CI runner, a new container, a git clean), which Expo's docs↗ describe as the way to reset it. The second is a hostname conflict reported by ngrok on connect, after which Expo CLI resets the random part and retries. Logging in or out of your Expo account changes the address too, since the username is part of it.
Any of these means a new address, so the QR code has to be scanned again, and an OAuth redirect or webhook registered against the old address stops working.
Connections drop frequently
The ngrok agent runs as a child process of Expo CLI. When its session closes, Expo CLI prints Tunnel connection has been closed., blames "intermittent connection issues between the dev server and ngrok", and tells you to restart the dev server. Restarting the dev server restarts the tunnel as well, since the agent stops with Expo CLI. The Expo docs↗ list this message among the tunnel's known intermittent issues.
It's slow
It is slower than LAN by about a second on a small app. I downloaded the iOS bundle of the blank SDK 57 template (3.8 MB) five times per path from the same Mac, with curl and an expo-platform: ios header:
| Path | Median | Range |
|---|---|---|
LAN (192.168.x.x:8081) | 0.019 s | 0.017 to 0.019 s |
Expo tunnel (exp.direct, plain HTTP) | 1.05 s | 0.99 to 1.35 s |
| LocalCan Public URL (Amsterdam edge, HTTPS) | 0.78 s | 0.76 to 0.78 s |
Reopening the app downloads the bundle again, and a bigger app means a bigger bundle. One blank app from one location is too small a sample to rank tunnels, but it shows the cost of a tunnel. A bundle download took up to 1.35 s through a tunnel and under 20 milliseconds on LAN. A 2025 100 MB download test of ngrok, Cloudflare Tunnel and LocalCan measured larger differences on big transfers.
Why it fails to start
These messages come from the Expo CLI source or from public Expo issues:
| Message | Cause | What to do |
|---|---|---|
Cannot read properties of undefined (reading 'body') | Expo's shared ngrok account refuses new sessions | Wait, or use your own tunnel |
failed to start tunnel remote gone away | The same account problem (the two alternated in #43335) | Wait, or use your own tunnel |
ngrok tunnel took too long to connect. | The agent did not connect within Expo CLI's 10-second timeout | Check VPN, firewall and antivirus, then retry |
The package @expo/ngrok@^4.1.0 is required to use tunnels, would you like to install it globally? | First run of --tunnel | Accept, or install the package in the project |
Install @expo/ngrok@^4.1.0 and try again | The install prompt was declined, or Expo CLI installed the package but could not load it | Install it in the project |
ERR_NGROK_121 | Your own free ngrok token patched into Expo CLI | Use a paid ngrok plan or a different tunnel |
In February 2026 the first two messages hit many developers at once. A developer in #43335↗ traced them to ngrok's cap of 5,000 simultaneous agent sessions on Expo's account (ERR_NGROK_108). Nothing on your machine fixes that. On October 10, 2026 the shared tunnel connected again.
The workaround that circulates in the same thread patches your own ngrok authtoken into node_modules/@expo/cli. On a free ngrok account it fails with ERR_NGROK_121, because the bundled agent is version 2.3.41 and, as of February 2026, ngrok required 3.20.0 or newer on free accounts (comment in #43335↗).
The Expo docs also mention Windows users who had to change their antivirus settings before ngrok worked. For the install errors, a project-local install avoids the global lookup: npm install --save-dev @expo/ngrok@^4.1.0. That is how the runs in this post were set up, and Expo CLI found the package without a prompt.
How LocalCan Solves This
Expo's advice is a tunnel on your own account, and Expo CLI has a documented way to use one. The EXPO_PACKAGER_PROXY_URL environment variable overrides the address Expo puts in the QR code, and it works with any tunnel that forwards HTTP and WebSockets to port 8081, ngrok or Cloudflare Tunnel on your own account included (see other ngrok alternatives). With LocalCan:
- The tunnel runs outside Expo. Stopping Metro leaves the Public URL up, and starting Metro again picks up the same address.
- The address stays put. On paid plans a Public URL stays reserved while it is connected and for up to 7 days when paused (how long a Public URL stays reserved).
- The limits are your own. They come from your plan, not from a pool shared with every Expo user.
- Pricing. The Free plan has one Public URL at a time on trylocalcan.com, for 60-minute sessions. URLs that persist start with Solo at $10 a month or $96 a year (pricing↗).
Setting Up LocalCan with Expo
Step 1: Start Expo Without Tunnel
Run Expo the normal way, without --tunnel:
npx expo start
Metro listens on port 8081 unless you pass --port, and LAN is the default, so --lan is optional (Expo docs↗).
Step 2: Create a Public URL for port 8081
From the terminal, with the LocalCan CLI:
localcan http 8081
Connecting tunnel to http://localhost:8081...
Forwarding https://jolly-mouse.localcan.dev → localhost:8081
The full output also lists two .local addresses, left out here. This is a quick tunnel, with a random name that goes away when you press Ctrl+C. For an address that stays the same from day to day, add Metro to a project file:
name: My RN App
services:
metro:
target: http://localhost:8081
endpoints:
- provider: localcan
Run localcan reload and LocalCan assigns a URL and writes it back into the file. In the desktop app, the same thing is Add Public URL on an app's card, then New Public URL.
Step 3: Connect Your Device
Stop Expo and start it again with the Public URL in EXPO_PACKAGER_PROXY_URL:
EXPO_PACKAGER_PROXY_URL=https://jolly-mouse.localcan.dev npx expo start
Expo now prints the Public URL where the LAN address used to be, and the QR code encodes it:
› Metro: exp://jolly-mouse.localcan.dev
Scan the QR code from the phone. Expo Go 57 on iOS shows no "Enter URL manually" row on its home screen, but a project you have opened stays under Recently opened, so after the first scan the Public URL is one tap away. In the iOS Simulator, run xcrun simctl openurl booted exp://jolly-mouse.localcan.dev to open it.
Three details from the runs:
- Expo's docs show the variable with
http://. Bothhttp://andhttps://worked through LocalCan and printed the sameexp://address. - Set the variable in the shell or in a
package.jsonscript. With the same line in.env, Expo printedenv: export EXPO_PACKAGER_PROXY_URLand still servedexp://192.168.50.96:8081. The Expo CLI source↗ reads this variable from the environment as it was before.envloaded, so a.envfile cannot redirect connected devices. - Older guides use
REACT_NATIVE_PACKAGER_HOSTNAME. Expo CLI still reads it, but it only swaps the hostname and keeps port 8081 in the address, so it cannot point at a tunnel served on the standard HTTPS port.
Step 4: Develop Normally
Hot reload runs over the tunnel. I changed a line in App.js and the simulator showed it within 3 seconds, with Expo logging iOS Bundled 30ms index.js (1 module).
Then I stopped Expo and left the tunnel running. The Public URL stayed up and answered with a 502 and LocalCan's LC1001 error:
{"error":"app_connection_refused","code":"LC1001","message":"LocalCan reached the computer behind this URL, but nothing is listening on localhost:8081.","host":"jolly-mouse.localcan.dev","target":"localhost:8081","edge":"ams"}
Starting Expo again with the same variable brought the app back at the same address, with no new QR code. Reload the app after restarting Metro, as you would on LAN, because the hot-reload connection does not come back by itself.
The daily routine:
# once per session (skip it with a project file)
localcan http 8081
# every time you start Expo
EXPO_PACKAGER_PROXY_URL=https://jolly-mouse.localcan.dev npx expo start
Real Device Testing: Why This Matters
The simulator is great for layout work and basic interactions. Some features only make sense on a real device:
Camera and sensors. The iOS Simulator has no camera, and no simulator or emulator gives you a real accelerometer or gyroscope. If your app uses expo-camera, expo-sensors, or AR features, you need a physical device.
Performance profiling. The simulator runs on your Mac's CPU, which is nothing like an actual phone. Janky animations and slow list rendering often only show up on real hardware, especially mid-range Android devices.
Gestures and haptics. A simulator approximates multi-finger gestures with the mouse and gives no haptic feedback. If your UX depends on them, test on a device.
In all of these cases the phone needs a steady connection to Metro. When the phone is on cellular or on a network that blocks LAN traffic between devices, that connection has to go through a tunnel.
Testing OAuth and Deep Links
OAuth providers require you to register redirect URIs in advance. When the tunnel address changes, you update your Google, Apple, or GitHub OAuth app configuration again. A Public URL from a project file on a paid plan keeps one address, so you register it once:
https://my-api.localcan.dev/auth/callback
The flow then looks like this:
- User taps "Sign in with Google" in your RN app
- App opens the browser to Google's OAuth consent screen
- User authorizes, Google redirects to
https://my-api.localcan.dev/auth/callback - Your local API exchanges the code and hands the session back to the app through a deep link
Universal links and Android App Links depend on the domain too, so a stable domain keeps that configuration valid across sessions. For the built-in tunnel, Expo's docs point to the experimental EXPO_TUNNEL_SUBDOMAIN variable for testing universal links.
Testing Webhooks from Your RN Backend
Many React Native apps have a backend that receives webhooks from Stripe, Firebase, Twilio, or other services. If that backend runs locally next to Metro, it needs a Public URL for webhook delivery too. Each port gets its own:
| Service | Local Port | Public URL |
|---|---|---|
| Expo dev server | 8081 | https://my-rn-app.localcan.dev |
| Backend API | 3000 | https://my-api.localcan.dev |
With both in a project file on a paid plan, the addresses stay the same, and restarting either server leaves its tunnel up. You point Stripe at https://my-api.localcan.dev/webhooks/stripe once. The Stripe webhook testing guide has the rest of that setup.
Using .local Domains for Same-Network Testing
If your phone and Mac share a Wi-Fi network, a local domain gives Metro a readable name instead of an IP address that changes between networks. LocalCan serves it at an address like https://myapp.local through its proxy on ports 80 and 443, and an iPhone needs the LocalCan root certificate for HTTPS. I did not test this path with Expo Go, so treat it as a starting point.
Expo vs. LocalCan Tunnel: Side-by-Side
Expo Tunnel (--tunnel) | LocalCan | |
|---|---|---|
| URL persistence | Same hostname until .expo is cleared, ngrok reports a conflict, or your Expo login changes | Project file on a paid plan: same URL, reserved up to 7 days when paused. Quick tunnel or Free plan: a new URL per run or session |
| Survives an Expo restart | No, the agent stops with Expo CLI | Yes, the tunnel runs separately |
| Account | Shared by every Expo user, no service-level guarantee | Your own |
| Bundle download (3.8 MB, my runs) | 1.05 s median | 0.78 s median |
| Setup | One flag (--tunnel) | One command plus EXPO_PACKAGER_PROXY_URL |
| Cost | $0 (Expo's shared account) | Free plan (60-minute sessions), Solo $10/month or $96/year |
| Multiple services | Metro only | Any port |
| Inspect tunnel traffic | Not shown by Expo (the ngrok agent serves its own inspector on localhost:4040) | Built-in request inspector |
Expo's tunnel wins on setup and price: one flag, no account, no cost. For an occasional test on a phone that cannot reach your LAN, that is enough.
Debugging Connection Issues
If your device can't connect through LocalCan, check these in order:
- Does Expo print your Public URL? The
› Metro:line should showexp://plus your Public URL. If it shows a LAN IP, the variable did not reach Expo CLI. Set it in the shell, not in.env. - Is LocalCan pointing to the right port? Expo defaults to 8081, but check your terminal output.
- Is the tunnel up? Run
localcan tunnel ls. If the URL answers withLC1001and "nothing is listening on localhost:8081", start Expo. - Physical iPhone? Expo Go on a physical iOS device opens a project only when Expo CLI and Expo Go are signed in to the same Expo account (Expo docs↗).
- Did you restart Metro? Reload the app. The hot-reload connection does not reconnect by itself.
- Is your device using the right URL? Check you're not opening an old
exp.directentry from Expo Go's recently opened list. - For .local domains: Both your Mac and phone must be on the same Wi-Fi network.
FAQ
Does this work with Expo Dev Client (custom builds)?
Yes. With expo-dev-client installed, Expo CLI builds the development build link from the same proxy URL. Use the https:// form of the variable there. Expo CLI builds the development build link with the variable's scheme, and LocalCan's edge answers plain http:// with a redirect to HTTPS.
Can I use LocalCan with EAS Build?
EAS Build runs in the cloud and doesn't need a tunnel to your local machine. LocalCan is for local development, when you're running expo start and connecting devices to your dev server.
Does this work for Android development?
A Public URL is reachable from any phone with internet access, Android or iOS. The runs used the iOS Simulator, so Android is untested. LocalCan runs on macOS and Windows, with a CLI-only build for Linux.
Is LAN mode (--lan) good enough? Why bother with a tunnel?
LAN mode works when your phone and Mac are on the same network. It falls apart on a corporate network with client isolation, when you're tethering, or when you need a public address for OAuth, webhooks, or sharing with teammates.
LocalCan is at localcan.com↗, with a Free plan to try the setup above.
