The http package, published by the Dart team itself, is the standard, officially-supported way to make network requests in Flutter — simpler than the lower-level dio package for basic REST calls, and the right default starting point for most apps.
1. Installation
flutter pub add http
2. A basic GET request
import 'package:http/http.dart' as http;
import 'dart:convert';
Future<List<Product>> fetchProducts() async {
final response = await http.get(Uri.parse('https://api.example.com/products'));
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(response.body);
return data.map((json) => Product.fromJson(json)).toList();
} else {
throw Exception('Failed to load products (status ${response.statusCode})');
}
}
Checking response.statusCode explicitly before trusting response.body matters — http.get() resolves normally (it doesn't throw) even for a 404 or 500 response; only a genuine network-level failure (no connection, DNS failure) throws an exception on its own. Skipping the status check and jsonDecode-ing an error response's body directly is a common source of confusing crashes.
3. A model class with fromJson
class Product {
final int id;
final String name;
final double price;
Product({required this.id, required this.name, required this.price});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'],
name: json['name'],
price: (json['price'] as num).toDouble(),
);
}
}
A fromJson factory constructor is the standard Dart pattern for turning a decoded JSON map into a typed object — casting numeric fields explicitly (as num).toDouble() here) matters because JSON numbers can decode as either int or double in Dart depending on the actual value, and a field expected to always be a double needs that explicit conversion to avoid a runtime type error on values that happen to arrive as whole numbers.
4. POST with a JSON body
Future<void> createProduct(String name, double price) async {
final response = await http.post(
Uri.parse('https://api.example.com/products'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'name': name, 'price': price}),
);
if (response.statusCode != 201) {
throw Exception('Failed to create product');
}
}
Unlike some higher-level HTTP clients, http.post() doesn't automatically JSON-encode a map passed as the body — the Content-Type header and jsonEncode() call both need to be set explicitly, or the server receives the body as a plain, unparseable string rather than valid JSON.
5. Using FutureBuilder to display the result in the UI
FutureBuilder<List<Product>>(
future: fetchProducts(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
final products = snapshot.data!;
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) => ListTile(title: Text(products[index].name)),
);
},
)
FutureBuilder is the standard Flutter widget for rendering different UI states (loading, error, success) based on a Future's current status — it re-renders automatically as the future's state changes, without needing manual setState() calls tied to the network request.
6. Setting a timeout
final response = await http
.get(Uri.parse('https://api.example.com/products'))
.timeout(const Duration(seconds: 10));
Without an explicit timeout, a hung or extremely slow connection leaves the request pending indefinitely from the app's perspective — wrapping the call with .timeout() and handling the resulting TimeoutException gives the UI a defined, bounded point at which to show an error instead of an indefinite loading spinner.