Skip to content
Blog

Flutter Networking with the http Package: Requests, Errors, and Timeouts

A complete, testable networking layer in Flutter with package:http: GET and POST with JSON, base URLs through flavors, timeouts, typed exceptions, cancellation, MockClient tests, and when to move to dio.

Published on • October 31, 2026

AI Assistant

Most Flutter apps need the same five things from the network: send a request, parse JSON, fail loudly and specifically, stop waiting after a deadline, and be testable without a server. The http package — published by the Dart team, http: ^1.6.0, supporting Android, iOS, macOS, Windows, Linux and the web — covers all five. The Flutter networking overview says it plainly: “http provides the simplest way to issue http requests.”

This post builds the layer properly: a service class with an injected Client, typed exceptions, timeouts, cancellation, environment-aware base URLs, and tests that run offline.

Adding the package

flutter pub add http
dependencies:
  http: ^1.6.0

Two platform notes before any code: Android must declare <uses-permission android:name="android.permission.INTERNET" /> in AndroidManifest.xml, and macOS needs com.apple.security.network.client set to true in the entitlements file. Skip them and your requests fail silently on those platforms — a maddening first hour of debugging.

Client instances, not top-level functions

The package exposes convenient top-level calls (http.get, http.post), but the README’s testing advice is the deciding factor: use a Client instance so it can be injected and mocked. The Client is also where you get connection reuse across requests to the same server.

import 'dart:convert';
import 'package:http/http.dart' as http;

class ApiClient {
  ApiClient({http.Client? client, required this.baseUrl})
      : client = client ?? http.Client();

  final http.Client client;
  final String baseUrl;

  Uri _uri(String path, [Map<String, String>? query]) =>
      Uri.parse('$baseUrl$path').replace(queryParameters: query);

  void close() => client.close();
}

GET and POST with JSON

Encode request bodies with jsonEncode and a Content-Type: application/json header; decode responses with jsonDecode. For non-ASCII payloads, decode response.bodyBytes through utf8 rather than trusting the default charset:

class PostApi {
  PostApi(this._api, {this.token});
  final ApiClient _api;
  final String? token;

  Future<List<Post>> fetchPosts() async {
    final res = await _api.client.get(
      _api._uri('/posts'),
      headers: {'Accept': 'application/json'},
    );
    _check(res);
    final list = jsonDecode(utf8.decode(res.bodyBytes)) as List<dynamic>;
    return list
        .map((e) => Post.fromJson(e as Map<String, dynamic>))
        .toList();
  }

  Future<Post> createPost({required String title, required String body}) async {
    final res = await _api.client.post(
      _api._uri('/posts'),
      headers: {
        'Content-Type': 'application/json; charset=utf-8',
        'Authorization': 'Bearer $token',
      },
      body: jsonEncode({'title': title, 'body': body}),
    );
    _check(res);
    return Post.fromJson(
      jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>,
    );
  }
}

(_uri/_check are private helpers shown below; in a real project they live on ApiClient or in a small extension.)

Base URL from flavors, not from source

Hardcoding https://api.example.com guarantees someone ships the staging URL to production. Build the base URL into each flavor with --dart-define-from-file:

flutter run --flavor staging --dart-define-from-file=config/staging.json
{ "API_BASE_URL": "https://api.staging.example.com" }
class Env {
  static const apiBaseUrl = String.fromEnvironment('API_BASE_URL');
}

Now ApiClient(baseUrl: Env.apiBaseUrl) is correct by construction for dev, staging, and release, and the environment is visible in CI logs.

Status codes and typed exceptions

A 404 is not an exception in http — it is an ordinary response with statusCode == 404. If you ignore it, you will jsonDecode an error page and throw an unhelpful FormatException three frames away from the cause. Map responses to a small exception hierarchy at one place, the boundary of the networking layer:

sealed class ApiFailure implements Exception {
  const ApiFailure(this.message, {this.statusCode});
  final String message;
  final int? statusCode;
}

class NetworkFailure extends ApiFailure {
  const NetworkFailure([super.message = 'No connection']);
}
class TimeoutFailure extends ApiFailure {
  const TimeoutFailure() : super('Request timed out');
}
class ServerFailure extends ApiFailure {
  const ServerFailure(super.message, super.statusCode);
}
class ClientFailure extends ApiFailure {
  const ClientFailure(super.message, super.statusCode);
}

void _check(http.Response res) {
  if (res.statusCode >= 200 && res.statusCode < 300) return;
  final message = _serverMessage(res) ?? 'Request failed (${res.statusCode})';
  if (res.statusCode >= 500) throw ServerFailure(message, res.statusCode);
  if (res.statusCode == 401 || res.statusCode == 403) {
    throw ClientFailure('Not authorized', res.statusCode);
  }
  throw ClientFailure(message, res.statusCode);
}

sealed classes let the UI switch exhaustively over failure kinds and pick the right recovery — retry button, re-login prompt, or “we’ll be back.”

Timeouts

package:http does not impose a timeout; a stalled server holds your future forever. Wrap calls with Future.timeout, or catch the timeout explicitly with onError/on TimeoutException:

const kTimeout = Duration(seconds: 15);

Future<http.Response> _send(Future<http.Response> Function() run) async {
  try {
    return await run().timeout(kTimeout);
  } on TimeoutException {
    throw const TimeoutFailure();
  } on http.ClientException catch (e) {
    throw NetworkFailure(e.message);
  } on SocketException catch (e) {
    throw NetworkFailure(e.message);
  }
}

Each call site then runs through the same gate: await _send(() => client.get(_uri('/posts'))). One shared deadline for the whole API keeps UX consistent: every screen knows a failure arrives within 15 seconds, which is what makes skeletons and retry affordances honest.

Note the SocketException: it comes from dart:io, which does not exist on the web. If you ship a web build, keep dart:io types behind a conditional import, or simply treat http.ClientException as the transport failure on all platforms — IOClient wraps most socket problems in it anyway.

Canceling requests

Two levels of cancellation, both real in current http:

  • Cancel everything: client.close() aborts outstanding requests on that client. Call it when the service is disposed (for example, in a Provider’s dispose, or when a logged-in user’s session is torn down).
  • Cancel one request: use AbortableRequest with an abortTrigger future via client.send(...). When the trigger completes, the response future (or the response stream) completes with a RequestAbortedException.
final abort = Completer<void>();
final request = http.AbortableRequest('GET', uri, abortTrigger: abort.future);

// later, e.g. when the user leaves the search screen:
abort.complete();

That is the correct way to handle “user typed another query”: abandon the in-flight search rather than let it complete and overwrite newer results.

When to upgrade to dio

http is deliberately small. Reach for dio — or write the equivalent yourself — when you actually need:

  • Interceptors for a cross-cutting concern: attaching auth headers, refreshing tokens once per 401, logging, request signing. http has a composability story for this (BaseClient subclasses that wrap another Client and mutate requests), and it works, but dio’s interceptor chain is more ergonomic when there are several.
  • Per-request cancellation as a first-class token (CancelToken) instead of the abort-trigger plumbing.
  • Upload/download progress and response streaming conveniences.
  • Retries with policy. http ships package:http/retry.dart with a RetryClient that retries 503s with backoff — often that is enough; dio is for richer, conditional retry rules.

Honest trade-off: dio adds a dependency, its own error types, and a layer of magic on top of dart:io. For an app with a dozen endpoints and no auth refresh dance, http plus the 60 lines above is smaller, faster to read, and easier to test. Move to dio when the interceptor list stops being hypothetical.

Testing with MockClient

package:http/testing gives you MockClient, which is why the injected client was worth the ceremony:

import 'package:http/http.dart' as http;
import 'package:http/testing.dart';

void main() {
  test('fetchPosts parses the list', () async {
    final client = MockClient((request) async {
      expect(request.url.path, '/posts');
      return http.Response(
        '[{"id":1,"title":"hi","body":"there"}]',
        200,
        headers: {'content-type': 'application/json'},
      );
    });

    final api = PostApi(ApiClient(client: client, baseUrl: 'https://test'));
    final posts = await api.fetchPosts();

    expect(posts, hasLength(1));
    expect(posts.first.title, 'hi');
  });

  test('500 maps to ServerFailure', () async {
    final client = MockClient((_) async => http.Response('boom', 500));
    final api = PostApi(ApiClient(client: client, baseUrl: 'https://test'));

    expect(api.fetchPosts(), throwsA(isA<ServerFailure>()));
  });
}

Assert on the contract — request path, headers, and the exception type — not on internal calls. These tests run in plain flutter test, no emulator and no network.

Security: never ship secrets in the binary

Do not hardcode API keys in Dart source, --dart-define values, or assets — all three are recoverable from a release build. --obfuscate --split-debug-info=... renames symbols; it is not secrecy, and treating it as such is a common audit finding. Patterns that hold up:

  • Public keys only on the client. Anything prefixed like a publishable key must be safe to expose.
  • Backend-for-frontend (BFF). The app authenticates the user (OAuth/OIDC with PKCE through a secure session or platform auth), and your server holds the privileged credentials and calls third parties.
  • Short-lived tokens, refreshed server-side, stored in Keychain/Keystore (flutter_secure_storage) rather than SharedPreferences.
  • Certificate pinning only with a rotation plan; a pinned certificate you cannot roll is an outage waiting to happen.

Putting it together

Prefer an async service class over FutureBuilder doing the fetching itself: FutureBuilder is for rendering a future you already own (usually from a provider), and recreating the future in build() re-fires the request on every rebuild. The shape that composes cleanly:

final apiProvider = Provider<ApiClient>(
  (ref) => ApiClient(baseUrl: Env.apiBaseUrl),
);

final postsProvider = FutureProvider<List<Post>>(
  (ref) => ref.watch(postApiProvider).fetchPosts(),
);

// in the widget:
// final posts = ref.watch(postsProvider);
// posts.when(loading: ..., error: (e, _) => ..., data: ...)

The widget stays a renderer; ApiClient owns HTTP; exceptions cross one boundary as sealed failures; tests substitute a MockClient. Requests, errors, timeouts, and cancellation are then boring — which is exactly what you want from infrastructure.

Further reading