DEV Community

Cover image for ApiClientPlus: Building a Production-Ready Flutter HTTP Client
Cahyanudien Aziz Saputra
Cahyanudien Aziz Saputra

Posted on

ApiClientPlus: Building a Production-Ready Flutter HTTP Client

How we achieved 225x faster API calls with intelligent caching and zero memory leaks


As Flutter developers, we've all been there: wrestling with Dio interceptors, implementing cache logic from scratch, managing multiple API endpoints, and dealing with authentication token refresh flows. After building dozens of Flutter apps, I decided enough was enough β€” it was time to create a solution that handles all of this elegantly.

Today, I'm excited to share ApiClientPlus, a production-ready HTTP client that makes API management in Flutter actually enjoyable.


🎯 The Problem: API Management is Hard

Before we dive into the solution, let's talk about what's broken. Here are the pain points I've encountered in nearly every Flutter project:

1. Cache Management is Tedious

// The old way: Manual cache hell
final cachedData = await _storage.get(key);
if (cachedData != null && !_isExpired(cachedData)) {
  return cachedData;
}

try {
  final response = await dio.get(url);
  await _storage.set(key, response);
  return response;
} catch (e) {
  if (cachedData != null) {
    return cachedData; // Fallback
  }
  rethrow;
}
Enter fullscreen mode Exit fullscreen mode

This is boilerplate hell. And we're only scratching the surface β€” what about different cache strategies? TTL management? Cache invalidation?

2. Multiple APIs = Multiple Headaches

// Managing different API clients manually
final authDio = Dio(BaseOptions(baseUrl: 'https://auth.api.com'));
final publicDio = Dio(BaseOptions(baseUrl: 'https://public.api.com'));
final cdnDio = Dio(BaseOptions(baseUrl: 'https://cdn.api.com'));

// Which one do I use? How do I switch? Where's my token?
Enter fullscreen mode Exit fullscreen mode

3. Authentication Token Refresh

// The token refresh dance
dio.interceptors.add(InterceptorsWrapper(
  onError: (error, handler) async {
    if (error.response?.statusCode == 401) {
      final newToken = await refreshToken();
      // Retry request...
      // Handle edge cases...
      // Hope nothing breaks...
    }
  },
));
Enter fullscreen mode Exit fullscreen mode

Sound familiar? There had to be a better way.


πŸš€ The Solution: ApiClientPlus

After months of refinement and battle-testing in production apps, ApiClientPlus emerged as a comprehensive solution that handles all of this and more.

Key Features at a Glance

βœ… 6 intelligent cache strategies with automatic expiration

βœ… Multi-API management with seamless switching

βœ… Token authentication with auto-refresh

βœ… 12ms average overhead (blazing fast!)

βœ… Zero memory leaks (tested extensively)

βœ… Production-ready with comprehensive error handling


πŸ’‘ How It Works

1. One-Time Setup (It's Actually Simple)

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await ApiClientPlus().initialize(
    configs: [
      ApiConfig(
        name: 'api',
        baseUrl: 'https://api.example.com',
        requiresAuth: true,
      ),
    ],
    defaultDomain: 'api',
    cacheConfig: CacheConfig(
      enableCache: true,
      defaultTtl: Duration(minutes: 10),
    ),
    tokenGetter: () async => await getToken(),
  );

  runApp(MyApp());
}
Enter fullscreen mode Exit fullscreen mode

That's it. One initialization, and you're done.

2. Making API Calls (The Easy Part)

// Simple GET with intelligent caching
final response = await ApiClientService.get(
  '/users',
  useCache: true,
  cacheStrategy: ApiClientCacheStrategy.cacheFirst,
);

// POST with automatic authentication
final user = await ApiClientService.post(
  '/users',
  data: {'name': 'John Doe'},
);
Enter fullscreen mode Exit fullscreen mode

Notice what's not there? No manual cache checks. No token handling. No boilerplate. Just clean, readable code.


🎨 The Magic: Cache Strategies

This is where ApiClientPlus really shines. Instead of implementing cache logic from scratch every time, choose from six battle-tested strategies:

1. Cache First (Default Smart Choice)

final data = await ApiClientService.get(
  '/dashboard',
  cacheStrategy: ApiClientCacheStrategy.cacheFirst,
);
Enter fullscreen mode Exit fullscreen mode

How it works: Checks cache first, falls back to network if needed.

Best for: General-purpose API calls, user profiles, app settings.

2. Network First (Fresh Data Priority)

final posts = await ApiClientService.get(
  '/posts',
  cacheStrategy: ApiClientCacheStrategy.networkFirst,
);
Enter fullscreen mode Exit fullscreen mode

How it works: Tries network first, uses cache as fallback.

Best for: Feeds, news, content that changes frequently.

3. Stale While Revalidate (Performance King πŸ‘‘)

This is my favorite. Here's why:

final products = await ApiClientService.get(
  '/products',
  cacheStrategy: ApiClientCacheStrategy.staleWhileRevalidate,
);
Enter fullscreen mode Exit fullscreen mode

How it works: Returns cached data instantly, refreshes in background.

Result: Your users see data in 2ms instead of 450ms, while fresh data loads silently.

Real-world impact:

  • ⚑ Instant perceived performance
  • 🎯 Happy users (no loading spinners!)
  • πŸ”„ Always fresh data (updates in background)

4. Cache Then Network (Progressive Enhancement)

final dashboard = await ApiClientService.get(
  '/dashboard',
  cacheStrategy: ApiClientCacheStrategy.cacheThenNetwork,
  onCachedResponse: (cached) {
    // Show cached data immediately
    updateUI(cached.data);
  },
);
// Fresh data arrives, UI updates again
Enter fullscreen mode Exit fullscreen mode

Best for: Complex dashboards, progressive loading UX.

5. Cache Only (Offline Mode)

final saved = await ApiClientService.get(
  '/saved-articles',
  cacheStrategy: ApiClientCacheStrategy.cacheOnly,
);
Enter fullscreen mode Exit fullscreen mode

Best for: Offline-first apps, saved content.

6. Network Only (Real-Time Data)

final livePrice = await ApiClientService.get(
  '/stock-price',
  cacheStrategy: ApiClientCacheStrategy.networkOnly,
);
Enter fullscreen mode Exit fullscreen mode

Best for: Live data, real-time updates, financial apps.


πŸ“Š The Numbers: Performance That Matters

We didn't just build this β€” we obsessively tested it. Here's what we found:

Benchmark Results

Operation                Time        Result
─────────────────────────────────────────────
API Call Overhead        ~12ms       βœ… Excellent
Cache Retrieval          ~2ms        ⚑ Instant
Route Matching           ~600ΞΌs      πŸš€ Blazing
Plugin Init              ~13ms       🏎️ Quick
Memory Leaks             0 growth    πŸ›‘οΈ Solid
Enter fullscreen mode Exit fullscreen mode

Real-World Cache Impact

🌐 First Request (Network):  450ms
⚑ Second Request (Cache):    2ms

Result: 225x FASTER! πŸŽ‰
Enter fullscreen mode Exit fullscreen mode

This isn't theoretical β€” this is what happens in production, on real devices, with real users.

Load Testing

We pushed it hard:

  • βœ… 100 sequential calls: 1.1 seconds (11ms avg)
  • βœ… 50 domain switches: 549ms (10.98ms avg)
  • βœ… 1000 route matches: 627ms (0.6ms avg)
  • βœ… Zero memory leaks after 20+ create/dispose cycles

🏒 Managing Multiple APIs (Finally Easy!)

One of my favorite features is multi-API management. Here's how it works:

await ApiClientPlus().initialize(
  configs: [
    ApiConfig(
      name: 'auth',
      baseUrl: 'https://auth.company.com',
      requiresAuth: true,
    ),
    ApiConfig(
      name: 'public',
      baseUrl: 'https://api.company.com',
      requiresAuth: false,
    ),
    ApiConfig(
      name: 'cdn',
      baseUrl: 'https://cdn.company.com',
    ),
  ],
  defaultDomain: 'auth',
);
Enter fullscreen mode Exit fullscreen mode

Now switching between APIs is trivial:

// User authentication
final profile = await ApiClientService.get(
  '/me', 
  domainName: 'auth',
);

// Public content
final posts = await ApiClientService.get(
  '/posts', 
  domainName: 'public',
);

// CDN assets
final image = await ApiClientService.get(
  '/avatar.png', 
  domainName: 'cdn',
);
Enter fullscreen mode Exit fullscreen mode

Each API can have its own configuration, timeouts, headers, and authentication requirements. No more juggling multiple Dio instances!


πŸ”’ Authentication Made Simple

Token management and refresh flows are notoriously tricky. ApiClientPlus handles it elegantly:

await ApiClientPlus().initialize(
  configs: [...],
  tokenGetter: () async {
    // Your token retrieval logic
    return await SecureStorage.read('auth_token');
  },
  onTokenInvalid: () async {
    // Handle expired tokens
    await SecureStorage.delete('auth_token');
    Navigator.pushReplacementNamed(context, '/login');
  },
);
Enter fullscreen mode Exit fullscreen mode

Now when a 401 occurs:

  1. onTokenInvalid is called automatically
  2. You handle the logout/refresh logic
  3. User is redirected appropriately

No interceptor spaghetti code required.


πŸ› Debugging: Know What's Happening

Development without good logging is like flying blind. ApiClientPlus gives you comprehensive visibility:

LogConfig(
  showLog: true,
  showCacheLog: true,
  logLevel: "DEBUG",
  prettyJson: true,
  isColored: true,
)
Enter fullscreen mode Exit fullscreen mode

Sample output:

INFO πŸ’Ύ Cache Strategy: cache_first for /users
INFO πŸ’Ύ βœ… CACHE HIT: /users (age: 2m 34s)
INFO πŸ“₯ Response: 2ms | 200 | from_cache: true

INFO 🌐 Cache Strategy: network_first for /posts
INFO 🌐 Making network request...
INFO πŸ“₯ Response: 156ms | 200 | from_cache: false
INFO πŸ’Ύ βœ… Cached: /posts (ttl: 10m)
Enter fullscreen mode Exit fullscreen mode

You know exactly what's happening, when, and why. No more guessing.


🎯 Real-World Example

Let's build a simple app that fetches and displays posts:

import 'package:flutter/material.dart';
import 'package:api_client_plus/api_client_plus.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await ApiClientPlus().initialize(
    configs: [
      ApiConfig(
        name: 'dummyjson',
        baseUrl: 'https://dummyjson.com',
      ),
    ],
    defaultDomain: 'dummyjson',
    cacheConfig: CacheConfig(
      enableCache: true,
      defaultTtl: Duration(minutes: 5),
    ),
  );

  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: PostsScreen(),
    );
  }
}

class PostsScreen extends StatefulWidget {
  @override
  _PostsScreenState createState() => _PostsScreenState();
}

class _PostsScreenState extends State<PostsScreen> {
  List<dynamic> posts = [];
  bool loading = true;

  @override
  void initState() {
    super.initState();
    fetchPosts();
  }

  Future<void> fetchPosts() async {
    try {
      final response = await ApiClientService.get(
        '/posts',
        useCache: true,
        cacheStrategy: ApiClientCacheStrategy.staleWhileRevalidate,
      );

      setState(() {
        posts = response.data['posts'];
        loading = false;
      });
    } catch (e) {
      setState(() => loading = false);
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Error: $e')),
      );
    }
  }

  @override
  Widget build(BuildContext context) {
    if (loading) {
      return Scaffold(
        body: Center(child: CircularProgressIndicator()),
      );
    }

    return Scaffold(
      appBar: AppBar(title: Text('Posts')),
      body: RefreshIndicator(
        onRefresh: fetchPosts,
        child: ListView.builder(
          itemCount: posts.length,
          itemBuilder: (context, index) {
            final post = posts[index];
            return ListTile(
              title: Text(post['title']),
              subtitle: Text(post['body']),
            );
          },
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

With staleWhileRevalidate, this app:

  • Shows cached posts instantly on subsequent opens
  • Refreshes data in the background
  • Provides smooth pull-to-refresh
  • Handles errors gracefully

All in ~80 lines of code!


πŸ› οΈ Advanced Features

Custom Headers & Query Parameters

final response = await ApiClientService.get(
  '/users',
  headers: {
    'X-Custom-Header': 'value',
    'X-Request-ID': generateUUID(),
  },
  query: {
    'page': 1,
    'limit': 20,
    'sort': 'desc',
  },
);
Enter fullscreen mode Exit fullscreen mode

Timeouts & Retries

ApiConfig(
  name: 'api',
  baseUrl: 'https://api.example.com',
  connectTimeout: Duration(seconds: 30),
  receiveTimeout: Duration(seconds: 30),
  maxRetries: 3,  // Auto-retry failed requests
)
Enter fullscreen mode Exit fullscreen mode

Cache Fallback on Errors

CacheConfig(
  hitCacheOnNetworkFailure: true,
  hitCacheOnErrorCodes: [500, 502, 503, 401, 403],
)
Enter fullscreen mode Exit fullscreen mode

When these error codes occur, ApiClientPlus automatically falls back to cached data if available. Your app stays functional even when the backend is down!


πŸŽ“ Lessons Learned

Building ApiClientPlus taught me several valuable lessons:

1. Performance Matters More Than You Think

Users notice the difference between 450ms and 2ms. That's the difference between a "loading" state and instant content. Intelligent caching isn't just a nice-to-have β€” it's essential for great UX.

2. Flexibility > Opinionated

I initially built this with one cache strategy in mind. But different screens need different strategies. A news feed needs fresh data. A user profile can be stale. Settings can be cached aggressively. Six strategies emerged from real needs.

3. Testing Prevents Disasters

The extensive performance tests caught memory leaks early. The route matching tests prevented production bugs. If you're building infrastructure code like this, test obsessively.

4. Developer Experience is King

The best library is one developers actually want to use. Clean APIs, good docs, and helpful error messages matter more than features.


πŸš€ Getting Started

Ready to try it? Installation is simple:

dependencies:
  api_client_plus: ^1.0.0
Enter fullscreen mode Exit fullscreen mode

Then run:

flutter pub get
Enter fullscreen mode Exit fullscreen mode

Check out the full documentation and GitHub repo for more examples and guides.


🀝 Community & Contribution

ApiClientPlus is open source and built for the Flutter community. If you:

  • πŸ› Find a bug
  • πŸ’‘ Have a feature idea
  • πŸ“– Want to improve docs
  • ⭐ Just want to show support

Head over to GitHub and get involved!


🎯 What's Next?

We're not stopping here. The roadmap includes:

  • GraphQL support for modern APIs
  • Request/response transformers for data mapping
  • Offline queue for failed requests
  • Analytics integration for monitoring
  • Mock mode for testing

Have ideas? Open an issue on GitHub!


πŸ’­ Final Thoughts

Building apps shouldn't mean fighting with infrastructure. API management, caching, authentication β€” these are solved problems. ApiClientPlus lets you focus on what matters: building great features for your users.

The 225x performance improvement isn't magic. It's just smart caching, done right.

Give it a try in your next Flutter project. I think you'll love it.


πŸ“š Resources


If this article helped you, please give it a πŸ”₯ and follow for more Flutter content!

Have questions? Drop them in the comments below. I read and respond to every one.


Tags: #Flutter #Dart #MobileDevelopment #API #Performance #OpenSource #Cache #HTTP

Top comments (0)