The forgotten flag that shipped a broken build

A small friendly creature holds up a freshly shipped app package on a real phone, but the phone's screen shows a road that loops straight back into the phone itself, ending at a tiny dark empty room labeled localhost, while a healthy glowing server waits unreached in the distance
My release build was a parcel delivered with the wrong address written inside. It rode all the way to a real phone, opened the map, and found the only road led right back into its own pocket. The server was awake and waiting the whole time, just somewhere the app was never told to look.

I built a Flutter release of my bedtime-story app, installed it on a real phone, and it was dead on arrival. Sign-in spun and failed. Story generation spun and failed. Every time, the same gut feeling: the backend must be down.

Except the backend was fine. I hit /healthz from my laptop and got a 200. I hit it from the phone’s own browser over cellular and got a 200. The server was up, reachable, serving. The app, on that same phone, couldn’t talk to it at all.

That gap is the tell. Server healthy, app blind. The app wasn’t failing to reach the internet. It was failing to reach the right host, and it turned out the right host had been quietly compiled out of the build hours earlier, by a flag I forgot to pass.

The symptom: a five-second timeout, device only

The client talks to the backend through Dio, with a short connect timeout:

// client/lib/common/api_client.dart
final dio = Dio(BaseOptions(
  baseUrl: cfg.baseUrl,
  connectTimeout: const Duration(seconds: 5),
  headers: {'X-App-Version': appVersion},
));

So the failure mode was: tap “Sign in”, wait five seconds, get a DioException with type: connectionTimeout. Not a 401, not a 500, not a TLS error. Nothing that implies the server ever saw the request. The connection simply never opened.

The other tell: it only failed on the device. On the simulator during development, everything worked perfectly. Which is exactly why it slipped through. The bug is invisible in the one environment where I do all my testing.

The root cause: a release build with localhost baked in

The client reads its backend URL from a compile-time define, with a fallback:

// client/lib/config.dart  (BEFORE)
const override = String.fromEnvironment('API_BASE_URL');
final baseUrl = override.isNotEmpty ? override : 'http://localhost:3001';

String.fromEnvironment is resolved at build time from --dart-define=API_BASE_URL=.... My dev invocations passed it. So did CI. My one-off “let me just build a release to test this on my phone” invocation did not.

So the release build fell through to the default, http://localhost:3001, and baked it into the binary. On a phone, localhost is the phone itself. There’s no backend listening there. Every request dials a port on the handset, finds nothing, and times out after five seconds. Login and generation failed for the same reason: there was never a server at the address the app was built to call.

The insidious part is that nothing warned me. The build succeeded. The app installed. The UI rendered. The default was a perfectly valid URL, just the wrong one for a shipping build, and wrong in a way that only surfaces on real hardware. A forgotten flag silently degraded to a dev config.

The fix: let the build mode choose the default

The flag being forgettable isn’t really the bug. The bug is that forgetting it fails silently, in production, instead of loudly, in dev. So I made the default depend on the build mode:

// client/lib/config.dart  (AFTER)
factory AppConfig.fromEnvironment() {
  const override = String.fromEnvironment('API_BASE_URL');
  final resolved = override.isNotEmpty
      ? override
      : (kReleaseMode ? _prodBaseUrl : _localDefaultBaseUrl);
  return AppConfig(baseUrl: resolved, certPinAssetPath: 'assets/cert_pin.txt');
}

static const _prodBaseUrl = 'https://kidsstories.apps.nudgelabs.xyz';

// On Android, the emulator reaches the host machine via 10.0.2.2; on other
// platforms (iOS simulator, desktop) a locally-running backend is on localhost.
// Note this branches on platform, not emulator status: a physical Android
// debug/profile build also gets 10.0.2.2, which won't resolve on a real device.
// Physical devices need an explicit --dart-define pointing at a LAN, staging,
// or production URL.
static String get _localDefaultBaseUrl =>
    defaultTargetPlatform == TargetPlatform.android
        ? 'http://10.0.2.2:3001'
        : 'http://localhost:3001';

Three behaviors, in precedence order:

  1. An explicit --dart-define=API_BASE_URL always wins. Staging, a LAN IP, any custom host: unchanged. Nothing about the override path moved.
  2. Release builds default to production. kReleaseMode is a compile-time constant, so a flutter build in release with no define now bakes in the prod URL instead of localhost. A forgotten flag can no longer ship a store build pointed at my laptop.
  3. Debug and profile builds keep the local-first default, still platform-aware (Android’s 10.0.2.2 host alias versus localhost everywhere else). Note this branches on platform, not on emulator status, so a physical Android debug or profile build also lands on 10.0.2.2, which won’t resolve on the device; testing on real hardware still means passing an explicit --dart-define with a LAN, staging, or production URL. But the emulator/simulator/desktop dev loop is untouched.

(The prod URL uses HTTPS. Certificate pinning is disabled for this host: the platform’s Let’s Encrypt leaf certs rotate too often to pin, so the cert-pin asset ships as a placeholder and the client falls back to standard CA validation. That’s orthogonal to this bug, but worth noting: the release default is a real, TLS-terminated host, not just a hostname swap.)

Defaults should fail toward safety

A cozy cutaway of a wooden circuit breaker box where a big friendly switch rests in the off position by default, its little room dark but calmly noticeable, while a warning bell hangs ready to ring the instant something is missed
I want my defaults to rest where a forgotten flip is caught at once, like a breaker whose resting position leaves the room dark the moment you walk in. A quiet mistake that waits to burn later, somewhere I am not watching, is the one I never want to build in again.

The old default optimized for the wrong thing. localhost is the most convenient default, because you’re almost always running against a local backend. But convenience in the common case bought a silent catastrophe in the rare one. The single build that matters most, the store build, got the worst possible value with zero warning.

Here’s the reframe that fixed it. A good default is the one whose failure mode is loud and early, not silent and late. With the build-mode-aware version:

  • Forget the flag on a release build, and it points at prod. Correct by accident. No harm done.
  • Forget the flag on a debug build, and it points at localhost. If my local backend isn’t running, it fails immediately, on my machine, while I’m looking at it. Loud and early.

So the omission plays out differently depending on the build. In a release build it’s harmless: you fall through to prod, which is what a shipped build wants anyway, so nothing surfaces and nothing needs to. In a debug or profile build it surfaces immediately in dev, the moment you run against a local backend that isn’t up. Neither path can quietly ship localhost to the App Store. The dangerous configuration, localhost in a shipped build, is no longer reachable by omission. You’d have to explicitly pass --dart-define=API_BASE_URL=http://localhost... to a release build, which nobody does by accident.

Think of it like the labels on a circuit breaker. If the unlabeled default position is “off”, the worst case of a forgotten flip is a dark room you notice the second you walk in. If the unlabeled default is “on for the one wire that shouldn’t be”, you don’t find out until something downstream burns, quietly, later, somewhere you weren’t watching. The safe default is the one whose mistakes announce themselves.

That said, the point isn’t that convenient defaults are wrong. For a value that’s harmless when it falls back, convenient is exactly right, and adding build-mode logic would be ceremony for nothing. The point is narrower: when a value has a safe setting and a convenient setting, and they genuinely differ, don’t make convenient the default and hope everyone remembers to flip it. Make the context choose. Here the context was free. kReleaseMode already knew whether this was a shipping build. I just wasn’t asking it.

A forgotten flag will always be forgettable. The fix is to make forgetting it harmless.

← all writing