DEV Community

Kashyap Bhanu Das
Kashyap Bhanu Das

Posted on

Choosing the Best Networking Tool for a Scalable Flutter App: Dio vs Retrofit vs Chopper

When designing the architecture of a large-scale Flutter app, the networking layer is one of the most consequential decisions you'll make. It's not just about making HTTP requests — it directly impacts:

  • How scalable and maintainable your codebase is
  • How easily teams can onboard and iterate
  • How robust your error handling, testing, and debugging flows are

We evaluated Dio, Retrofit, and Chopper — not based on surface-level ergonomics, but on:

  • How they scale across hundreds of endpoints
  • How they handle evolving backend contracts
  • How testable and mockable they are
  • How safely teams can build in parallel without regressions

🚦 Why This Comparison?

Our requirements for the networking layer were clear. It had to:

  • Fit clean architecture principles (separation between domain, data, and presentation layers)
  • Be mockable and testable (abstract service interfaces, interceptors)
  • Handle dynamic headers, tokens, and retries with clean layering
  • Scale to hundreds of APIs without growing unmaintainable
  • Integrate well with CI/CD and analytics tools

🔧 Option 1: Dio — The Manual but Powerful Approach

Dio is a low-level but full-featured HTTP client. It offers:

✅ Fine-grained interceptors, cancellation, and retry support
✅ Great for custom logic, debugging hooks, and analytics
✅ Actively maintained and widely adopted

But it comes with tradeoffs:

⚠️ No compile-time safety — typos or structure mismatches show up at runtime
🧱 High boilerplate for large codebases
🔍 Harder to mock and test — requires custom wrappers or adapters

Example:

Future<LoginResponse> login(LoginRequest request) async {
  final response = await _dio.post('/auth/login', data: request.toJson());
  if (response.statusCode == 200) {
    return LoginResponse.fromJson(response.data);
  } else {
    throw Exception('Failed');
  }
}
Enter fullscreen mode Exit fullscreen mode

Repeat that for 150+ endpoints — and it quickly becomes unscalable.


📦 Option 2: Retrofit — The Declarative, Type-Safe Layer on Dio

Retrofit builds on Dio and uses annotations + code generation to define your APIs.

✅ Minimal boilerplate with clean interfaces
✅ Compile-time safety for endpoints and payloads
✅ Abstract classes make mocking and testing easy
✅ Codegen integrates directly with Dio interceptors

Example:

@RestApi(baseUrl: 'https://api.example.com')
abstract class AuthApi {
  factory AuthApi(Dio dio, {String baseUrl}) = _AuthApi;

  @POST('/auth/login')
  Future<LoginResponse> login(@Body() LoginRequest request);
}
Enter fullscreen mode Exit fullscreen mode

This simplifies both implementation and testing. Your repositories call api.login(), and the response is already deserialized.

Tradeoffs:

⚠️ Less flexibility for highly dynamic APIs
⚠️ Regeneration required on API contract changes
⚠️ No per-method converters or custom response envelopes out-of-the-box


🧩 Option 3: Chopper — Extensible and Service-Oriented

Chopper offers Retrofit-like structure but is built on top of http.Client, not Dio.

✅ Supports service-oriented patterns
✅ Response wrappers include headers and status
✅ Custom converters and plugin-style extensibility

Example:

@ChopperApi(baseUrl: "/v1")
abstract class ApiService extends ChopperService {
  static ApiService create() => _$ApiService();

  @Get(path: "/user/json")
  Future<Response<User>> getJsonUser();
}
Enter fullscreen mode Exit fullscreen mode

Tradeoffs:

📉 Smaller ecosystem than Dio or Retrofit
⚠️ No Dio support (interceptors, retries, etc.)
🛠️ Slightly more boilerplate than Retrofit
🔌 Fewer plugins for advanced use cases (e.g. file upload, custom auth flows)


🔄 Real-World Refactor Example

Before (Dio):

Future<OtpResponse> requestOtp(OtpRequest request) async {
  final response = await _dio.post('/auth/request-otp', data: request.toJson());
  if (response.statusCode == 200) {
    return OtpResponse.fromJson(response.data);
  } else {
    throw Exception('Failed');
  }
}
Enter fullscreen mode Exit fullscreen mode

After (Retrofit):

@POST('/auth/request-otp')
Future<OtpResponse> requestOtp(@Body() OtpRequest request);
Enter fullscreen mode Exit fullscreen mode

Now it's just:

final result = await api.requestOtp(request);
Enter fullscreen mode Exit fullscreen mode

✅ Deserialized
✅ Error-wrapped
✅ Testable and clean


🧠 Decision: Retrofit + Dio

For a Flutter app built at scale, stacking Retrofit on top of Dio gave us the best of both worlds:

  • Structure and codegen from Retrofit
  • Power and configurability from Dio
  • Clear architectural boundaries for domain/data separation
  • Easy testing and mocking via abstract interfaces

🚫 Why Not Chopper?

Chopper is conceptually solid and extensible, but the lack of Dio support, smaller ecosystem, and limited plugin integrations made it harder to justify. If it supported Dio natively, we might have made a different call.


🧱 Dio Remains the Foundation

Even with Retrofit, Dio stays under the hood — handling interceptors, base config, cancellation tokens, and retries. Retrofit simply adds a declarative, testable layer on top.


🔍 Impact

✅ When having 150+ endpoints, we estimate 5,000–8,000 lines of code will be either eliminated or simplified by switching to Retrofit.
✅ Onboarding new devs is faster — clear Retrofit interfaces, minimal custom parsing
✅ ~4x faster test setup, fewer test-specific implementations per feature


🧩 Bonus: What Else

As part of architecture evolution, we’re also reviewing:

  • State management (Bloc vs Riverpod vs Provider vs GetX)
  • Flutter CI/CD pipelines (Azure DevOps, Firebase, fastlane)
  • Error and analytics instrumentation

💬 Your Turn

How is your team structuring the networking layer in large Flutter apps?
Are you using raw clients, Retrofit, or something else?

Let’s exchange notes — drop your thoughts below 👇


🔗 Further Reading

📖 Full article on Medium

Top comments (0)