skip to content

In Flutter with package:http, how do you send a GET and a JSON POST, and why reuse one http.Client instead of calling http.get each time?

level: juniorimportance: must knowfreq 62%

answer

  1. top-level functions open and close a client
  2. persistent connections live in the Client
  3. a Map body is form-encoded
  4. 404 is a Response, not an exception
  5. close() before the isolate exits

basics

~10 s

Call client.get(uri) or client.post(uri, headers: {'Content-Type': 'application/json'}, body: jsonEncode(data)) on one long-lived http.Client and check response.statusCode. The top-level http.get creates and closes a new Client for every call, losing persistent connections.

solid answer

~40 s

`package:http` has convenience functions (`http.get`, `http.post`) that each create a `Client`, send one request and close it. A `Client` is what keeps persistent connections to a server, so an app that talks to one API should create one client, share it (for example through a provider), and call `close()` when it is no longer needed; the docs call the behaviour undefined if a client is never closed before its isolate exits. `get` and `post` return a `Response` for every status code, so you check `statusCode` yourself; only transport failures throw a `ClientException` (`read` and `readBytes` also throw on a non-success status). For JSON, pass `jsonEncode(body)` with a `Content-Type: application/json` header, because a `Map` body is sent as form fields with a content type you cannot override.

code

dart · 31 lines
dart
import 'dart:convert';

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

class MembershipApi {
  MembershipApi(this._client);

  final http.Client _client;
  static final _base = Uri.https('api.example-gym.test', '/v1/');

  Future<Map<String, Object?>> membership(String id) async {
    final response = await _client.get(_base.resolve('members/$id'));
    if (response.statusCode != 200) {
      throw StateError('membership lookup failed: ${response.statusCode}');
    }
    return jsonDecode(response.body) as Map<String, Object?>;
  }

  Future<void> checkIn(String id, String clubId) async {
    final response = await _client.post(
      _base.resolve('check-ins'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'memberId': id, 'clubId': clubId}),
    );
    if (response.statusCode != 201) {
      throw StateError('check-in failed: ${response.statusCode}');
    }
  }

  void dispose() => _client.close();
}

go deeper

for a junior

Know get and post on an http.Client, checking statusCode, and sending JSON with jsonEncode plus a Content-Type header.

for a middle

Explain why one shared Client keeps persistent connections, when ClientException is thrown versus a non-2xx Response, and how BaseClient adds headers.

for a senior

Own the client's lifetime through dependency injection, close it with its owner, and check release-only setup such as the Android INTERNET permission.

for a principal

Decide whether the app standardises on package:http or dio, and where the single client instance and its policies live.

## Two ways to call `package:http` `package:http` is the Dart team's composable, `Future`-based HTTP library, and it runs on Android, iOS, desktop and the web. It offers two styles: - **Top-level functions** such as `http.get(uri)` and `http.post(uri, ...)`. Each one creates a fresh `Client`, sends one request and closes the client in a `finally` block. - **A `Client` instance** with the same methods (`get`, `post`, `put`, `patch`, `delete`, `head`, `read`, `readBytes`, `send`). You create it once and call it many times. The package's own documentation says: if you plan to make multiple requests to the same server, use a single `Client` for all of them. ## Why one client matters A `Client` is the object that **maintains persistent connections**. With one shared client, the second request to your gym-membership API can reuse the TCP and TLS connection opened by the first. With the top-level functions, every call pays for a new connection and then throws it away. Practical shape in a Flutter app: 1. Create the client once, near the top of the app or in a repository object. 2. Provide it to the code that needs it (constructor injection, `provider`, `get_it` or Riverpod). 3. Call `client.close()` when the owner is disposed. The docs warn that behaviour is undefined if `close` is not called before the isolate that created the client exits, and that calling it while requests are active is undefined too. The default `Client()` factory returns an `IOClient` where `dart:io` is available and a `BrowserClient` on the web. ## Reading the response - `response.statusCode` is an `int`; `response.body` is the decoded text; `response.bodyBytes` the raw bytes; `response.headers` a map with lower-cased names. - **A 404 or 500 is not an exception.** `get` and `post` complete normally for every status, so the caller decides what counts as success. - **Transport failures** (no route to host, a dropped connection) throw a `ClientException`. - `read(uri)` and `readBytes(uri)` are shortcuts that return only the body and **do** throw a `ClientException` when the status is not a success. ## Sending JSON The `body` of `post`, `put` and `patch` may be a `String`, a `List<int>` or a `Map<String, String>`: | Body type | What is sent | Default content type | |---|---|---| | `String` | the text, encoded with `encoding` (UTF-8 by default) | `text/plain` | | `List<int>` | the bytes as given | none set | | `Map<String, String>` | form fields | `application/x-www-form-urlencoded`, which cannot be overridden | So a JSON API needs `body: jsonEncode(payload)` **and** a `Content-Type: application/json` header. Passing the map directly is a classic bug: the server receives form fields and rejects the request. ## A small client wrapper Many apps wrap the shared client in an API class that owns the base URL, adds common headers and turns non-success statuses into typed errors. For cross-cutting behaviour on every request, `package:http` expects you to **extend `BaseClient`** and override `send`, wrapping an inner `Client`; that is how an auth header can be added once for all calls. ## Where `package:http` stops and dio starts | Concern | `package:http` | `dio` | |---|---|---| | Non-2xx status | a normal `Response` | a `DioException` of type `badResponse` by default | | JSON body from a `Map` | no; form-encoded | yes; a `Map` is sent as JSON | | Response decoding | `body` is text; you call `jsonDecode` | JSON decoded into `response.data` by default | | Cross-cutting logic | wrap a `BaseClient` | an ordered list of interceptors | | Timeouts | no timeout parameter on the methods; wrap the future or configure the inner client | `connectTimeout`, `sendTimeout`, `receiveTimeout` options | Neither is "better": `package:http` is small and composable, dio bundles conveniences. What matters in an interview is knowing which defaults you are relying on. ## Platform setup that bites in release builds - **Android**: the app's main `AndroidManifest.xml` must declare `android.permission.INTERNET`. Flutter's templates add it to the debug and profile manifests only, so networking can work while developing and fail in the release build. - **macOS**: the entitlements files must include `com.apple.security.network.client`.

  • Networking works in debug but every request fails in the Android release build; why?
    Flutter's templates declare `android.permission.INTERNET` only in the debug and profile manifests. The release build uses the main `AndroidManifest.xml`, which must declare the permission itself.
  • How do you add an Authorization header to every request made through package:http?
    Extend `BaseClient`, keep an inner `Client`, and override `send(BaseRequest request)` to set `request.headers['Authorization']` before delegating to the inner client. Every convenience method funnels through `send`, so one place covers them all.
  • When is calling the top-level http.get acceptable?
    For a genuine one-off request, such as a script or a single startup check, where no connection would be reused anyway. For an app that talks to the same API repeatedly, one shared `Client` avoids opening a new connection per call.

saying these in an interview costs you the question

  • http.get throws an exception when the server answers 404.
  • Passing a Map as the post body sends JSON.
  • A Client never needs closing because Dart garbage-collects it.
  • Creating a new Client per request has no cost.
  • Setting Content-Type overrides the form encoding of a Map body.