Files, JSON & HTTP (dart:io, dart:convert)
By the end of this lesson you will be able to read and write files the right way (sync vs async, streaming a huge file instead of loading it all into memory), turn JSON text into validated typed Dart objects and back, talk HTTP from complete first principles (what a request/response actually looks like as bytes, status codes, timeouts, retries, streaming), and wire all three together into one real pipeline: fetch JSON from a server, parse it, save it to disk, and read it back. Every claim on this page is executed for real, offline, in verify/d19.dart.
1. Files: reading, writing, streaming
File object is just a piece of paper with an address written on it — creating File('notes.txt') does not touch the disk at all, it just remembers a path (where the file would live). Only when you call a method like .readAsString() does Dart actually walk over to the filing cabinet (the disk) and open the drawer. A Directory is the same idea for a folder. Both live in dart:io, which only exists outside the browser (command-line Dart, Flutter's non-web platforms) — a web page cannot touch your local disk for security reasons.The core file operations you'll use constantly:
file.readAsString()— reads the whole file into memory as oneString(async; returns aFuture<String>).file.readAsLines()— reads the whole file and splits it into aList<String>, one entry per line.file.openRead()— returns aStream<List<int>>of raw bytes as they arrive, without ever holding the entire file in memory at once — essential for huge files.file.writeAsString(text, {mode})— writes text to the file, async.
...Sync() twin (readAsStringSync, writeAsStringSync, …). The async versions ask the operating system to do the disk I/O in the background and hand you a Future immediately, so your isolate (see D14) stays free to do other work while waiting. The sync versions block the whole isolate until the disk responds — on a Flutter UI isolate that means a frozen, unresponsive screen for however long the disk takes. Rule of thumb: use the async API by default; reach for Sync only in one-shot command-line scripts where nothing else needs to run concurrently.folder + '/' + name — it breaks on Windows, where the separator is \. Use Platform.pathSeparator, or better, the package:path package (import 'package:path/path.dart' as p;), which gives you p.join('a', 'b', 'c.txt'), p.basename(path) (the file name), p.dirname(path) (the containing folder), and p.extension(path) (e.g. .txt) — all working correctly on every platform. The panel below shows the dependency-free equivalent (joinPaths, baseName, dirName, extensionOf) using Platform.pathSeparator directly.Now the "big file" problem. Imagine a 2 GB log file: readAsString() would try to load all 2 GB into memory at once — often a crash, always wasteful if all you need is, say, a word count. openRead() instead streams the bytes in small chunks; piping that through .transform(utf8.decoder) (turns bytes into text) and .transform(const LineSplitter()) (splits text into lines) gives you a Stream<String> you can process one line at a time with await for, holding only ONE line in memory at any instant.
Bytes, UTF-8 and base64. A file (and a socket) is just a list of bytes (whole numbers 0–255). UTF-8 is the rule that turns text into bytes: ASCII letters take 1 byte, accented letters 2, symbols like € 3, emoji 4 — so a string's length (UTF-16 units) is not its byte size. Because a chunk boundary can fall in the middle of a character, always decode a stream with utf8.decoder, never chunk by chunk. base64 re-writes any bytes as safe printable text (3 bytes become 4 characters, so the length is 4·⌈n/3⌉); the URL-safe variant swaps + and / for - and _.
Writing has the same async-by-default shape, plus one extra knob: mode.
await file.writeAsString('a', mode: FileMode.write); // create/OVERWRITE
await file.writeAsString('b', mode: FileMode.append); // add to the end
await file.writeAsString('c'); // mode defaults to FileMode.write!
writeAsString/writeAsBytes is FileMode.write, which overwrites the entire file — including anything an earlier append call put there. Forgetting mode: FileMode.append on a logging function is one of the most common real-world file bugs: your "log" silently keeps only the very last line written. Verified above: writing 'a' then appending 'b' gives 'ab', but the very next writeAsString('c') with no mode argument wipes it back down to just 'c'.Errors: a missing file, a permissions problem, or a full disk all throw a subtype of FileSystemException. Reading a file that doesn't exist specifically throws PathNotFoundException (a FileSystemException subtype) — catch that specific type when you want to react to "file not found" differently from other I/O errors.
try {
await File('missing.txt').readAsString();
} on PathNotFoundException catch (e) {
print('no such file: ${e.path}');
} on FileSystemException catch (e) {
print('some other file system error: ${e.message}');
}
Input size → what's feasible: a file up to ≈ 106 bytes (a few MB) → readAsString() / readAsLines() is fine (≈ 2 bytes of memory per character); 5·107 bytes (50 MB) → stream it, because the whole text would cost ≈ 108 bytes; 1010 bytes (10 GB) → reading it all is ≈ 100 s at 100 MB/s, so stream it or seek into it (the tail question). Use the async API everywhere on a UI isolate.
readAsString, not readAsStringSync) so you never freeze the isolate. Stream huge files with openRead() instead of loading them whole. Always pass mode: FileMode.append explicitly when you mean to append — the default silently overwrites. Catch PathNotFoundException specifically for "file doesn't exist".2. JSON: decode, encode, typed models
{...} (like a Dart Map), arrays [...] (like a Dart List), strings, numbers, and the literals true/false/null. Think of it as a universal shipping crate: any two programs, written in any two languages, can agree to pack and unpack data using these five shapes — which is exactly why it's the standard format for talking to web APIs.dart:convert's jsonDecode(text) turns a JSON string into plain Dart values: an object becomes Map<String, dynamic>, an array becomes List<dynamic>, and everything nests naturally — an object inside an array inside an object all decode the same way, recursively. jsonEncode(value) does the reverse: it turns Dart Map/List/String/num/bool/null values back into a JSON string.
jsonDecode is dynamic — Dart has no idea at compile time whether map['age'] is an int, a String, or missing entirely. Reading it as-is and immediately doing map['age'] + 1 compiles fine and can still explode at runtime the moment the server sends something unexpected. The fix, below, is to convert the raw Map into a real, validated Dart class exactly once, as close to the network boundary as possible.A typed model class needs two things: a fromJson factory that validates and converts, and a toJson method that converts back. D11 taught map patterns (if (value case {...}) {...}) for destructuring — they're a perfect fit here: a single pattern both checks the JSON has the right shape/types AND pulls the values out, all in one step.
class Address {
final String city;
final String zip;
Address({required this.city, required this.zip});
factory Address.fromJson(Map<String, dynamic> json) {
if (json case {'city': String city, 'zip': String zip}) {
return Address(city: city, zip: zip);
}
throw FormatException('Invalid Address JSON: $json');
}
Map<String, dynamic> toJson() => {'city': city, 'zip': zip};
}
Person.fromJson nests Address.fromJson inside its own pattern, plus a manual check for the optional email field (patterns can't express "String or null" quite as neatly, so a plain if handles that one):
factory Person.fromJson(Map<String, dynamic> json) {
if (json case {'name': String name, 'age': int age, 'address': Map<String, dynamic> addressJson}) {
final email = json['email'];
if (email != null && email is! String) {
throw FormatException('Person.email must be a String or null, got ${email.runtimeType}');
}
return Person(name: name, age: age, address: Address.fromJson(addressJson), email: email as String?);
}
throw FormatException('Invalid Person JSON: $json');
}
case simply fails to match and execution falls through to the throw FormatException(...), giving you ONE clear, deliberate error message at the boundary, instead of a confusing "type 'Null' is not a subtype of 'String'" crash three functions later, deep inside your app.For pretty, human-readable JSON (logging, config files, debugging), use JsonEncoder.withIndent instead of plain jsonEncode:
final pretty = const JsonEncoder.withIndent(' ').convert({'a': 1, 'b': [1, 2]});
// pretty ==
// {
// "a": 1,
// "b": [
// 1,
// 2
// ]
// }
jsonDecode('{"a":5}') gives you a Dart int; jsonDecode('{"a":5.0}') gives a double — even though both represent the mathematical value 5. A model that blindly does json['age'] as double will crash with a TypeError the moment a server sends a whole-number age like 30 instead of 30.0. Fix: accept either and convert explicitly (see the asDouble helper in the interview bank below). Both cases are verified in verify/d19.dart (intVsDouble).null both come back as Dart null from a Map lookup — decide up front whether your model treats "absent" and "explicitly null" the same way (usually yes). JSON has no date type at all: dates always arrive as plain strings (commonly ISO-8601, e.g. "2026-01-15T10:30:00.000Z"), and you must parse them yourself with DateTime.parse(str) — never assume a field named createdAt is automatically a DateTime.@JsonSerializable(), declare its fields, and run build_runner once to generate the fromJson/toJson boilerplate for you — the same idea as writing it by hand above, just automated and kept in sync automatically when you add a field. This lesson writes fromJson/toJson by hand so you understand exactly what that generated code is actually doing underneath.Input size → what's feasible: a document of ≈ 106 nodes (a few MB) → jsonDecode costs roughly 10 ms per MB on a typical laptop (measure on your own device), fine on any isolate; 108 bytes (100 MB) → ≈ 1 s of decoding plus ≈ 109 bytes of tree, so decode in a background isolate (D15) or switch to JSON Lines and stream (section 3); depth up to 104 is safe because the parser is iterative.
3. HTTP from zero
GET /users HTTP/1.1), a block of headers (metadata like Host:, Content-Type:), a blank line, and optionally a body (data, e.g. for a POST). The server's reply (the response) has the same shape: a status line (HTTP/1.1 200 OK), headers, a blank line, a body.The status code is a 3-digit number telling you, at a glance, how the request went:
| Range | Meaning | Common examples |
|---|---|---|
| 2xx | Success | 200 OK, 201 Created, 204 No Content |
| 3xx | Redirect | 301 Moved Permanently, 304 Not Modified |
| 4xx | Client error — you did something wrong | 400 Bad Request, 401 Unauthorized, 404 Not Found |
| 5xx | Server error — they did something wrong | 500 Internal Server Error, 503 Service Unavailable |
Dart's dart:io gives you both sides for free, no package needed: HttpServer.bind(address, port) starts listening for connections; HttpClient makes outgoing requests.
// SERVER
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((HttpRequest request) async {
request.response.headers.contentType = ContentType.json;
request.response.write(jsonEncode({'msg': 'hi'}));
await request.response.close(); // you MUST close every response
});
// CLIENT
final client = HttpClient();
final request = await client.getUrl(Uri.parse('http://127.0.0.1:${server.port}/hello'));
final response = await request.close();
final body = await utf8.decoder.bind(response).join();
print(jsonDecode(body)); // {msg: hi}
client.close(); // you MUST close the client when done
HttpClient in a much simpler one-call API for the common case — no manually closing streams for a single request: final res = await http.get(uri); print(jsonDecode(res.body)); for GET, or http.post(uri, body: jsonEncode(data)) for POST. It's the usual choice in app code; dart:io's HttpClient/HttpServer (used throughout this lesson, dependency-free) is what it's built on, and understanding the raw version is what lets you debug the friendlier one when something goes wrong.Real networks are unreliable: a server can hang, a connection can drop. .timeout(Duration) on any Future makes it fail with a TimeoutException instead of waiting forever if it doesn't settle in time — critically, it must wrap the network call itself (request.close()), not something you already finished awaiting. Note that .timeout() only stops YOU from waiting; it does not cancel the request that is still in flight, which is why the panel below also closes the client with force: true.
try {
final response = await request.close().timeout(Duration(milliseconds: 40));
} on TimeoutException {
print('server took too long');
}
A single timeout just fails fast — production code usually also retries, ideally with a growing delay between attempts (exponential backoff) so a struggling server gets breathing room instead of an instant flood of retries.
A response body doesn't have to arrive all at once. If the server never sets an exact length (no Content-Length header), the connection uses HTTP/1.1's chunked transfer-encoding and the client sees response.contentLength == -1 ("unknown ahead of time — just keep reading the stream until it ends"). HttpClientResponse IS a Stream<List<int>>, so you can process it incrementally with await for instead of buffering the whole thing with .join() — exactly the same streaming idea as reading a big file in section 1, just over a socket instead of a disk.
Streaming a body one JSON value per line (a "JSON Lines" / .jsonl feed, common for large API exports and log pipelines) raises a new question: what stops a fast producer from piling up thousands of unprocessed records in memory while a slow consumer catches up? The answer is backpressure — and in Dart, await for gives it to you automatically.
await for is built on a StreamIterator: after delivering one event it pauses the underlying subscription and only resumes it once your loop body's await (here, await onRecord(...)) completes. An async* producer's yield genuinely cannot proceed past a paused subscription — so a slow consumer naturally throttles a fast producer with zero manual buffering code. This is exactly what the Expert-tier "streaming JSON-lines processor" question below asks you to build and prove.https:// URLs — HttpClient handles the TLS handshake and certificate verification for you automatically. The one thing to know: plain HttpClient trusts the device's normal certificate store, which is enough for the vast majority of apps; only for extra-sensitive traffic (banking, health data) do teams add certificate pinning (checking the server's certificate against one specific expected value, not just "any certificate a trusted authority signed") to defend against a compromised or coerced certificate authority.Input size → what's feasible: ≈ 102 requests of 50 ms each → start them together with Future.wait on ONE HttpClient (one after another would cost 102 · 50 ms = 5 s, together ≈ 50 ms); 104 or more → cap the number in flight (a pool of ~8–32) instead of opening 104 sockets; a response ≥ 108 bytes → stream it with await for, never join() it.
.timeout() a network call and always close every response/client/server you open. Retry transient failures with backoff, never in a tight loop.4. Processes & stdin/stdout
Process.run(command, arguments) (from dart:io) launches another program as a subprocess, waits for it to finish, and gives you back a ProcessResult with its exitCode, stdout, and stderr as strings — perfect for "run this one tool and grab its output" scripts (a linter, git, a compiler). If you instead need to react to a long-running subprocess's output as it streams in, Process.start returns a live handle whose stdout/stderr are Stream<List<int>>, the same streaming shape as everything else in this lesson.
final result = await Process.run('echo', ['hello from Process.run']);
print(result.exitCode); // 0
print(result.stdout); // "hello from Process.run\n"
This example runs echo as a real program, which exists on macOS and Linux; on Windows echo is a shell built-in, so you would pass runInShell: true or pick a real executable.
stdout (the global IOSink that print() itself writes to) exposes the same two methods any IOSink does — the exact ones you already used on a file's openWrite() sink in section 1: write(x), which writes x with no automatic newline, and writeln(x), which always adds exactly one. print(x) is essentially stdout.writeln(x) plus a little extra handling for very long strings.
stdout.write('loading');
stdout.write('.');
stdout.write('.');
stdout.write('.');
stdout.writeln(); // finally move to a new line
// prints: loading...
stdout.write('a') then stdout.write('b') produces ab on one line, NOT two lines — a common surprise for anyone used to always reaching for print. Verified directly in verify/d19.dart (writeVsWriteln), by writing to a real IOSink and reading the bytes back.Input size → what's feasible: a command printing ≤ 106 bytes → Process.run (it buffers all of stdout and stderr in memory) is fine; a long-running command or one printing ≥ 108 bytes → Process.start and stream stdout; always read stderr as well, or the child can block on a full pipe.
5. Putting it together: fetch → parse → save → read back
Every idea in this lesson combines into one realistic pipeline: fetch a JSON array of users from a server, parse each one into a typed UserModel, save the whole collection to disk as pretty-printed JSON, then read the file back and reconstruct the exact same models — proving the round trip is lossless.
// 1) fetch
final request = await client.getUrl(Uri.parse('http://127.0.0.1:${server.port}/users'));
final response = await request.close();
final raw = jsonDecode(await utf8.decoder.bind(response).join()) as List<dynamic>;
// 2) parse into typed models
final users = raw.map((j) => UserModel.fromJson(j as Map<String, dynamic>)).toList();
// 3) save to a file, pretty-printed
final pretty = const JsonEncoder.withIndent(' ').convert(users.map((u) => u.toJson()).toList());
await file.writeAsString(pretty);
// 4) read it back and reconstruct the models
final reloadedRaw = jsonDecode(await file.readAsString()) as List<dynamic>;
final reloaded = reloadedRaw.map((j) => UserModel.fromJson(j as Map<String, dynamic>)).toList();
What can go wrong in that pipeline, and the exception type each failure raises:
Input size → what's feasible: n ≤ 104 users (≈ 1 MB) → exactly this pipeline (fetch whole, parse whole, write whole) is fine; n = 106 records (≈ 108 bytes) → keep them as JSON Lines and stream one record at a time (O(1) memory, section 3); the one thing never to skip at any size is the single validated fromJson at the boundary.
Quiz
Interview questions
Cheat sheet
| Concept | Rule / syntax |
|---|---|
| Read whole file | await file.readAsString() / readAsLines() — async, non-blocking |
| Stream a big file | file.openRead().transform(utf8.decoder).transform(LineSplitter()), then await for |
| Write / append | writeAsString(text, mode: FileMode.write | FileMode.append) — default is write (overwrites!) |
| Sync vs async | ...Sync() blocks the whole isolate; the async version returns a Future and keeps it free |
| File errors | PathNotFoundException (missing file) is a FileSystemException subtype |
| Paths | Platform.pathSeparator, or package:path's join/basename/dirname/extension |
jsonDecode | object → Map<String, dynamic>, array → List<dynamic>, recursively |
| Typed models | fromJson (validate + construct, D11 map patterns) / toJson (plain map) |
| Pretty JSON | const JsonEncoder.withIndent(' ').convert(value) |
| JSON pitfalls | no-decimal-point number → int, with one → double; no date type, dates are strings; missing key and JSON null both read as Dart null |
| json_serializable | codegen for fromJson/toJson via @JsonSerializable() + build_runner (D20) |
| Status codes | 2xx success, 3xx redirect, 4xx your request was wrong, 5xx their server was wrong |
| Server / client | HttpServer.bind(address, port) + .listen(); HttpClient().getUrl/postUrl(uri) then .close() |
| package:http | http.get(uri) / http.post(uri, body: ...) — a simpler wrapper over HttpClient |
| Timeout / retry | future.timeout(d) throws TimeoutException; retry failed attempts with growing (exponential) backoff delays |
| Streaming response | contentLength == -1 ⇒ chunked encoding; HttpClientResponse is itself a Stream<List<int>> |
| Always close | every HttpResponse, every HttpClient, every HttpServer — or sockets/tests hang |
| Processes | Process.run waits and returns a ProcessResult; Process.start streams stdout/stderr live |
| stdout | write() — no auto newline; writeln() — always one, same as print() |