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

A 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:

Every one of these has a ...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.
Paths, not strings glued together: never build a path with 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!
The default mode of 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.

Prefer the async API (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

JSON (JavaScript Object Notation) is a plain-text format for describing structured data using only five shapes: objects {...} (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.

The type you get back from 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');
}
If the map pattern doesn't match — a field is missing, or has the wrong type — the 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
//   ]
// }
int vs double: JSON's number type doesn't distinguish integers from decimals the way Dart does — but Dart's JSON decoder does, based purely on whether the text contains a decimal point. 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).
Nulls and dates: a missing key and a key whose value is JSON 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.
json_serializable (covered fully in D20) is a code-generation package: you annotate a class with @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

HTTP is a conversation between a client (your app) and a server (someone else's computer), conducted entirely in plain text over a network connection — think of it as writing a very structured letter and getting a very structured reply. The client's letter (the request) has a first line saying what it wants (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:

RangeMeaningCommon examples
2xxSuccess200 OK, 201 Created, 204 No Content
3xxRedirect301 Moved Permanently, 304 Not Modified
4xxClient error — you did something wrong400 Bad Request, 401 Unauthorized, 404 Not Found
5xxServer error — they did something wrong500 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
package:http (a separate, very popular pub package) wraps 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.

Under the hood, 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/TLS: everything above works identically for 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.

A request/response is just structured text: a status/request line, headers, blank line, optional body. 2xx = success, 4xx = your request was wrong, 5xx = their server was wrong. Always .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...
Calling 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.

This four-step shape — fetch → validate/parse → persist → reload — is the backbone of almost every real app's data layer, and the Expert-tier "offline-first cache" question below builds directly on it by adding a fallback: serve the last good on-disk copy when step 1 fails.

Quiz

Interview questions

Cheat sheet

ConceptRule / syntax
Read whole fileawait file.readAsString() / readAsLines() — async, non-blocking
Stream a big filefile.openRead().transform(utf8.decoder).transform(LineSplitter()), then await for
Write / appendwriteAsString(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 errorsPathNotFoundException (missing file) is a FileSystemException subtype
PathsPlatform.pathSeparator, or package:path's join/basename/dirname/extension
jsonDecodeobject → Map<String, dynamic>, array → List<dynamic>, recursively
Typed modelsfromJson (validate + construct, D11 map patterns) / toJson (plain map)
Pretty JSONconst JsonEncoder.withIndent(' ').convert(value)
JSON pitfallsno-decimal-point number → int, with one → double; no date type, dates are strings; missing key and JSON null both read as Dart null
json_serializablecodegen for fromJson/toJson via @JsonSerializable() + build_runner (D20)
Status codes2xx success, 3xx redirect, 4xx your request was wrong, 5xx their server was wrong
Server / clientHttpServer.bind(address, port) + .listen(); HttpClient().getUrl/postUrl(uri) then .close()
package:httphttp.get(uri) / http.post(uri, body: ...) — a simpler wrapper over HttpClient
Timeout / retryfuture.timeout(d) throws TimeoutException; retry failed attempts with growing (exponential) backoff delays
Streaming responsecontentLength == -1 ⇒ chunked encoding; HttpClientResponse is itself a Stream<List<int>>
Always closeevery HttpResponse, every HttpClient, every HttpServer — or sockets/tests hang
ProcessesProcess.run waits and returns a ProcessResult; Process.start streams stdout/stderr live
stdoutwrite() — no auto newline; writeln() — always one, same as print()