Harsh Mittal
Back to blog
2026-04-23·4 min read

Why Your Flutter App Fails in Production (And You Don't Know Why)

ProductionFlutterAlso on Medium

Why Your Flutter App Fails in Production (And You Don't Know Why)

Your app is already failing in production. You just don't know it yet.

Because if your logging still looks like this:

try {
  final data = await repo.fetchDashboard();
} catch (e) {
  print(e);
}

You've basically turned off visibility in release mode. No logs. No context. No clue what went wrong.

And when a user says "app not working" — you're blind.

Let's fix that properly.

The Core Problem (Nobody Talks About This)

Error handling in most Flutter apps is messy because one thing is trying to serve three different purposes: UI messaging, debugging, and monitoring (Crashlytics/Sentry).

That's why you see raw exceptions shown in the UI, logs missing in production, and zero structure in error tracking.

You need separation. Not hacks.

Step 1 — Define Failures Like a System, Not Random Exceptions

If you're still throwing Exception("Something went wrong"), you've already lost.

Define a base error model that carries intent:

sealed class AppFailure {
  final String userMessage;
  final String? internalMessage;
  final StackTrace? trace;
  final int? code;

  const AppFailure(
    this.userMessage, {
    this.internalMessage,
    this.trace,
    this.code,
  });
}

Why this matters: you now control what the user sees, and what you investigate.

  • User → "Something went wrong. Try again."
  • Dev → "POST /orders → 502 Bad Gateway"

That separation is non-negotiable.

Step 2 — Make Failures Explicit (Stop Guessing Later)

Instead of generic errors, define intent-based failures:

class ApiFailure extends AppFailure {
  const ApiFailure([super.userMessage = 'Unable to load data']);

  ApiFailure.debug({
    String message = 'Unable to load data',
    String? internalMessage,
    int? code,
    StackTrace? trace,
  }) : super(
          message,
          internalMessage: internalMessage,
          code: code,
          trace: trace,
        );
}

class SessionFailure extends AppFailure {
  const SessionFailure([super.userMessage = 'Session expired']);
}

class DataFormatFailure extends AppFailure {
  const DataFormatFailure([super.userMessage = 'Invalid data received']);
}

Now your errors are predictable, searchable, and maintainable.

Step 3 — One Logger, No Drama

You don't need a fancy logging framework. You need consistency.

import 'package:flutter/foundation.dart';

class Log {
  Log._();

  static void error(AppFailure f, {String? tag}) {
    if (kDebugMode) {
      debugPrint('\n==== ERROR ====');
      debugPrint('Type: ${f.runtimeType}');
      debugPrint('UserMsg: ${f.userMessage}');
      if (tag != null) debugPrint('Tag: $tag');
      if (f.code != null) debugPrint('Code: ${f.code}');
      if (f.internalMessage != null) {
        debugPrint('Details: ${f.internalMessage}');
      }
      if (f.trace != null) {
        debugPrint('Trace:\n${f.trace}');
      }
      debugPrint('==============\n');
    } else {
      // send to crash tool
    }
  }

  static void warn(String msg) {
    if (kDebugMode) debugPrint('[WARN] $msg');
  }

  static void debug(String msg) {
    if (kDebugMode) debugPrint('[DEBUG] $msg');
  }
}

Key design choices: kDebugMode is removed completely in release, a static class means no setup and no injection, and structured output means readable logs.

Step 4 — Real Usage (Not Toy Examples)

Case 1 — API call

Future<List<Order>> loadOrders() async {
  try {
    final res = await dio.get('/orders');
    return parseOrders(res.data);
  } on DioException catch (e, st) {
    final failure = ApiFailure.debug(
      code: e.response?.statusCode,
      internalMessage: '${e.requestOptions.path} → ${e.message}',
      trace: st,
    );
    Log.error(failure, tag: 'loadOrders');
    throw failure;
  } catch (e, st) {
    final failure = DataFormatFailure().copyWith(
      internalMessage: e.toString(),
      trace: st,
    );
    Log.error(failure, tag: 'loadOrders');
    throw failure;
  }
}

Case 2 — Silent failures (don't crash UX for these)

User? readUserFromCache() {
  try {
    final raw = storage.read('user');
    if (raw == null) return null;
    return User.fromJson(jsonDecode(raw));
  } catch (e) {
    Log.warn('Cache parse failed: $e');
    storage.delete('user');
    return null;
  }
}

Case 3 — UI layer (where most people mess up)

Widget buildAvatar(String url) {
  return Image.network(
    url,
    errorBuilder: (_, __, err) {
      Log.warn('Avatar load failed → $url | $err');
      return const Icon(Icons.person);
    },
  );
}

Case 4 — Debug flow tracking

Log.debug('Navigated to CheckoutScreen');
Log.debug('Cart items count: ${cart.length}');

Gone in production. No cost.

Step 5 — Stop Throwing, Start Returning (Cleaner Architecture)

Optional, but worth it. Instead of throwing from Future<List<Order>>, return Future<Result<List<Order>>>.

sealed class Result<T> {
  const Result();
}

class Ok<T> extends Result<T> {
  final T value;
  const Ok(this.value);
}

class Fail<T> extends Result<T> {
  final AppFailure error;
  const Fail(this.error);
}

Usage:

final result = await loadOrders();

switch (result) {
  case Ok(:final value):
    showOrders(value);
  case Fail(:final error):
    showToast(error.userMessage);
}

No hidden crashes. No missed cases.

Step 6 — Plug Into Real Monitoring

When you're ready, connect Firebase Crashlytics or Sentry:

FirebaseCrashlytics.instance.recordError(
  failure,
  failure.trace ?? StackTrace.current,
  reason: failure.internalMessage,
);

Because your failure already contains context, stack trace, and metadata, you get useful reports instantly.

Hard Truths (Most Teams Ignore This)

Logging is not optional — it's infrastructure. If you can't trace issues, your app is not production-ready. print() is not logging. Exceptions without structure are noise.

What I Would Do If I Joined Your Codebase

Kill every print(). Introduce AppFailure at the data layer. Wrap API and parsing calls with structured failures. Add a single logger. Integrate Crashlytics. Gradually move to the Result pattern.

No big refactor. Just controlled cleanup.

Final Thought

You don't need more logs. You need better logs.

Get new posts by email

No spam, no schedule — just an email when a new post goes up.