npx expo start --tunnel: Why It Fails and How to Use Your Own Tunnel
Updated on October 10, 2026   •   Published on February 5, 2026   •   13 min read

npx expo start --tunnel: Why It Fails and How to Use Your Own Tunnel

Jarek CeborskiJarek Ceborski

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↗:

ROFI
ROFI@bidah

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.

8:05 PM · Feb 5, 2026

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:

  1. Expo CLI finds @expo/ngrok, in the project or installed globally, and starts the ngrok agent it ships with
  2. The agent logs in with Expo's shared authtoken and requests a hostname like <random>-<user>-8081.exp.direct
  3. Expo serves the app manifest and the JavaScript bundle through that hostname
  4. Expo Go loads the app from exp://<random>-<user>-8081.exp.direct, and a development build loads the same host over https://

Here is a run on a blank SDK 57 app, with the QR code left out:

Text
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:

PathMedianRange
LAN (192.168.x.x:8081)0.019 s0.017 to 0.019 s
Expo tunnel (exp.direct, plain HTTP)1.05 s0.99 to 1.35 s
LocalCan Public URL (Amsterdam edge, HTTPS)0.78 s0.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:

MessageCauseWhat to do
Cannot read properties of undefined (reading 'body')Expo's shared ngrok account refuses new sessionsWait, or use your own tunnel
failed to start tunnel remote gone awayThe 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 timeoutCheck 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 --tunnelAccept, or install the package in the project
Install @expo/ngrok@^4.1.0 and try againThe install prompt was declined, or Expo CLI installed the package but could not load itInstall it in the project
ERR_NGROK_121Your own free ngrok token patched into Expo CLIUse 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:

Bash
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:

Bash
localcan http 8081
Text
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:

~/.localcan/projects/my-rn-app.yml
YAML ~/.localcan/projects/my-rn-app.yml
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:

Bash
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:

Text
› 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://. Both http:// and https:// worked through LocalCan and printed the same exp:// address.
  • Set the variable in the shell or in a package.json script. With the same line in .env, Expo printed env: export EXPO_PACKAGER_PROXY_URL and still served exp://192.168.50.96:8081. The Expo CLI source↗ reads this variable from the environment as it was before .env loaded, so a .env file 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:

Text
{"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:

Bash
# 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.

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:

Text
https://my-api.localcan.dev/auth/callback

The flow then looks like this:

  1. User taps "Sign in with Google" in your RN app
  2. App opens the browser to Google's OAuth consent screen
  3. User authorizes, Google redirects to https://my-api.localcan.dev/auth/callback
  4. 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:

ServiceLocal PortPublic URL
Expo dev server8081https://my-rn-app.localcan.dev
Backend API3000https://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 persistenceSame hostname until .expo is cleared, ngrok reports a conflict, or your Expo login changesProject 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 restartNo, the agent stops with Expo CLIYes, the tunnel runs separately
AccountShared by every Expo user, no service-level guaranteeYour own
Bundle download (3.8 MB, my runs)1.05 s median0.78 s median
SetupOne 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 servicesMetro onlyAny port
Inspect tunnel trafficNot 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:

  1. Does Expo print your Public URL? The › Metro: line should show exp:// 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.
  2. Is LocalCan pointing to the right port? Expo defaults to 8081, but check your terminal output.
  3. Is the tunnel up? Run localcan tunnel ls. If the URL answers with LC1001 and "nothing is listening on localhost:8081", start Expo.
  4. 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↗).
  5. Did you restart Metro? Reload the app. The hot-reload connection does not reconnect by itself.
  6. Is your device using the right URL? Check you're not opening an old exp.direct entry from Expo Go's recently opened list.
  7. 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.

More posts