Skip to content
Boring Doc
Boring Doc explanation Verified against Flutter 3.47.7 4 min readnetworkinghttpGETPOSTasync

Networking and JSON

HTTP, JSON models, futures and streams.

Use package:http to send a request, await the Future<Response>, check the status code, decode the body with jsonDecode and turn the resulting map into a typed model through a fromJson constructor. Kick the request off once (not in build), hand the Future to a FutureBuilder to show loading, error and data states, and move decoding of very large payloads onto a background isolate with compute.

  1. package:http works on Android, iOS, macOS, Windows, Linux and web; avoid dart:io or dart:html directly. Android needs the INTERNET permission in the manifest and macOS needs the network client entitlement.
  2. http.get(uri) and http.post(uri, headers:, body:) return Future<http.Response>; await them inside an async function and read response.statusCode and response.body.
  3. Non-success status codes do not throw. Check them yourself and throw an exception rather than returning null, otherwise FutureBuilder keeps showing the spinner.
  4. jsonDecode returns dynamic (a Map, List, String, num, bool or null). Cast it and convert to a model class with a fromJson constructor so the rest of the app is typed.
  5. Create the Future once (initState or a late final field) and pass it to FutureBuilder; a Future created inside build restarts the request on every rebuild.
  6. Decoding a very large document on the UI isolate causes jank. compute(parseFn, body) runs a top-level function on another isolate and returns the result. For many models, json_serializable generates fromJson/toJson instead of hand-written code.
Sequence diagramRequest lifecycle from widget to rebuild

The widget starts the request once, a repository talks to the server through http.Client, the response is validated and decoded (on a background isolate for big payloads), and the completed Future makes FutureBuilder rebuild.

Mail order with a packing slip in another language

A widget filling in an order form is the request; the postal service (http) carries it and brings a parcel back (the Response). The parcel has a packing slip in a foreign format (JSON text). Before you can shelve the goods you transcribe the slip into your own inventory sheet (fromJson into a model). FutureBuilder is the tracking page: 'in transit' (loading), 'delivered' (data) or 'lost' (error). If the shipment is enormous you do the transcription in the back room so the shop floor stays open (compute on another isolate).

Where the analogy breaks: Parcels arrive once; Futures can also time out, fail, or complete after the customer has walked out (the widget was disposed). Rebuilding the shop front must not place a new order, which is why the Future is created once and stored. And unlike a real warehouse, the back room (isolate) shares nothing: you can only pass it plain data, not open connections or widgets.

Minimal working example

GET, decode, model, FutureBuilder
dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

class Album {
  const Album({required this.id, required this.title});
  final int id;
  final String title;

  factory Album.fromJson(Map<String, dynamic> json) {
    return Album(id: json['id'] as int, title: json['title'] as String);
  }
}

Future<Album> fetchAlbum() async {
  final uri = Uri.parse('https://jsonplaceholder.typicode.com/albums/1');
  final response = await http.get(uri);
  if (response.statusCode != 200) {
    throw Exception('Request failed: ${response.statusCode}');
  }
  return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}

class AlbumPage extends StatefulWidget {
  const AlbumPage({super.key});

  @override
  State<AlbumPage> createState() => _AlbumPageState();
}

class _AlbumPageState extends State<AlbumPage> {
  late final Future<Album> _album = fetchAlbum();

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<Album>(
      future: _album,
      builder: (context, snapshot) {
        if (snapshot.hasError) return Text('Error: ${snapshot.error}');
        if (!snapshot.hasData) return const CircularProgressIndicator();
        return Text(snapshot.data!.title);
      },
    );
  }
}
Expected: A spinner shows until the request completes, then the album title. If the request fails or returns a non-200 status, the error text appears instead.Flutter ≥ 3.0.0Fetch data from the internet (cookbook)
  1. L10fromJson is the one place that knows the JSON shape; the casts throw a TypeError if the server changes a field's type.
  2. L17http.get returns a Future<Response>; await suspends this function without blocking the UI isolate.
  3. L18A 404 or 500 is a normal Response, not an exception. Throwing here makes FutureBuilder report hasError instead of spinning forever.
  4. L21jsonDecode returns dynamic; cast to Map<String, dynamic> for an object or List<dynamic> for an array.
  5. L32A late final field initialises on first use and only once, so rebuilds reuse the same Future. initState works equally well.
  6. L36FutureBuilder subscribes to the Future and rebuilds its builder when it completes.
  7. L39Check hasError before hasData: a failed Future has no data, and a null-returning Future never sets hasData.

Use cases

  • Use http.get plus a fromJson model for straightforward REST reads, and FutureBuilder (or a ChangeNotifier ViewModel) to render loading, error and data states.
  • Use http.post with a JSON-encoded body and Content-Type header to create or update resources; expect 201 for creation.
  • Use compute when decoding takes long enough to drop frames (large lists, deeply nested documents) and the parse function is top-level.
  • Use json_serializable when the project has many models or nested objects, so field names and types are checked by generated code instead of hand-written casts.

Benefits

  • One API across all six platforms; the http package abstracts dart:io and the browser's fetch.
  • Typed models catch typos and type mismatches at the boundary, so widgets never touch raw maps.
  • FutureBuilder and ChangeNotifier-based ViewModels make the three UI states explicit instead of implicit nulls.

Limitations

  • The http package has no interceptors, retries or caching built in; those are left to you or to a package such as dio.
  • Hand-written fromJson scales poorly: a renamed server field is a runtime error, not a compile error, unless you adopt code generation.
  • compute copies the message to and from the isolate and on the web runs on the main thread, so it pays off only for genuinely heavy parsing.

Alternatives and trade-offs

  • dio — You need interceptors, request cancellation, form data or built-in retry/logging on top of HTTP.
  • json_serializable or freezed — There are many or nested models and you want generated fromJson/toJson with compile-time field checks.
  • StreamBuilder with WebSockets or server-sent events — Data changes continuously and polling with GET would be wasteful.
  • A ChangeNotifier ViewModel instead of FutureBuilder — The screen needs refresh, retry or several requests, and a single Future no longer describes its state.

Starting the request inside build

Problem: build runs whenever anything above rebuilds. A Future created there fires a new HTTP request every time, and FutureBuilder restarts from the loading state because it received a different Future.

Fix: Create the Future once in initState (or as a late final field) and store it; pass that same object to FutureBuilder.

✗ New Future on every rebuild
dart
@override
Widget build(BuildContext context) {
  return FutureBuilder<Album>(
    future: fetchAlbum(), // re-requests on every build
    builder: (context, snapshot) => Text('${snapshot.data?.title}'),
  );
}
✓ Future stored once
dart
late Future<Album> _album;

@override
void initState() {
  super.initState();
  _album = fetchAlbum();
}

@override
Widget build(BuildContext context) {
  return FutureBuilder<Album>(
    future: _album,
    builder: (context, snapshot) => Text('${snapshot.data?.title}'),
  );
}

Returning null instead of throwing on a bad status code

Problem: snapshot.hasData is only true for a non-null value. A fetch that returns null on 404 completes 'successfully' with nothing, so the FutureBuilder shows the loading spinner forever and the user never sees an error.

Fix: Throw on any status you do not expect. The exception surfaces as snapshot.hasError, which the builder can render.

✗ Null hides the failure
dart
Future<Album?> fetchAlbum() async {
  final response = await http.get(_uri);
  if (response.statusCode != 200) return null; // spinner forever
  return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}
✓ Throw so hasError becomes true
dart
Future<Album> fetchAlbum() async {
  final response = await http.get(_uri);
  if (response.statusCode != 200) {
    throw Exception('Failed to load album: ${response.statusCode}');
  }
  return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
}

Passing the wrong thing to compute

Problem: compute serialises its message to another isolate. Passing an http.Response, a Future, a closure that captures a widget, or an instance method causes an error, and the parse function silently runs nowhere.

Fix: Send plain data (the response body String) and point compute at a top-level or static function that returns plain data (a List of simple objects).

Forgetting platform permissions

Problem: Requests that work on iOS and desktop fail on an Android release build with a SocketException, or on macOS with a sandbox denial, because the platform never granted network access.

Fix: Add <uses-permission android:name="android.permission.INTERNET" /> to AndroidManifest.xml and the com.apple.security.network.client entitlement to the macOS .entitlements files.

This page is Boring Doc’s independent explanation. For the complete specification use the official pages below (content © The Flutter Authors, CC BY 3.0; code samples BSD). Dates show when the explanation was last checked and when the official page last changed.

  • Networking Flutter 3.47.7 Explanation verified against current source
    https://docs.flutter.dev/data-and-backend/networking
    Last checked
    Oct 21, 2026
    Source last updated
    3 weeks ago
    Last synchronized
    3 weeks ago
  • Fetch data from the internet Flutter 3.47.7 Explanation verified against current source
    https://docs.flutter.dev/cookbook/networking/fetch-data
    Last checked
    Oct 21, 2026
    Source last updated
    3 weeks ago
    Last synchronized
    3 weeks ago
  • Send data to the internet Flutter 3.47.7 Explanation verified against current source
    https://docs.flutter.dev/cookbook/networking/send-data
    Last checked
    Oct 21, 2026
    Source last updated
    3 weeks ago
    Last synchronized
    3 weeks ago
  • Parse JSON in the background Flutter 3.47.7 Explanation verified against current source
    https://docs.flutter.dev/cookbook/networking/background-parsing
    Last checked
    Oct 21, 2026
    Source last updated
    3 weeks ago
    Last synchronized
    3 weeks ago
  • JSON and serialization Flutter 3.47.7 Explanation verified against current source
    https://docs.flutter.dev/data-and-backend/serialization/json
    Last checked
    Oct 21, 2026
    Source last updated
    3 weeks ago
    Last synchronized
    3 weeks ago
  • Fetch data from the internet (learning pathway) Flutter 3.47.7 Explanation verified against current source
    https://docs.flutter.dev/learn/pathway/tutorial/http-requests
    Last checked
    Oct 21, 2026
    Source last updated
    3 weeks ago
    Last synchronized
    3 weeks ago
  • https://api.flutter.dev/flutter/widgets/FutureBuilder-class.html
    Last checked
    Oct 21, 2026
Was this useful?
AI explanationAI-generated · grounded in cited sources
AI explanations are not configured
Set AI_PROVIDER (for example to openai-compatible with a local Ollama or LM Studio endpoint) to enable grounded explanations. Everything else on this page works without an AI provider.