GET reads, POST creates, PUT replaces, PATCH changes part, DELETE removes; 2xx is success, 3xx redirect, 4xx the client's mistake, 5xx the server's. React by code: refresh a 401 once, do not retry 403 or 404, back off and retry 429 and 5xx.
Idempotent methods: GET, PUT and DELETE can be sent twice with the same result; a POST cannot
401 Unauthorized: the token is missing or expired: refresh it once, and if that fails send the user to login
403 Forbidden: the server knows who you are and still says no; a refresh will not help, so show "no access"
404 Not Found: show an empty or "not found" state, and do not retry
400 / 422: validation failed: show the server's field errors next to the inputs
429, 5xx and timeouts: temporary: retry with backoff, honour Retry-After, and only repeat requests that are safe to repeat
FlutterMiddle
Networking & REST
http, Dio, REST APIs, JSON serialization, interceptors
Questions closed
0 / 25
- 0Review
- 4Asked often
Questions
Start the request once (in initState or in your state-management layer), keep the Future in a field, and let FutureBuilder render the three states from its snapshot.
Create the future once: not in build, which reruns on every rebuild and would resend the request
Read the snapshot: waiting → spinner, error → an error view with a retry button, data → content
**Check waiting first**: on retry the snapshot keeps the previous error and data while the new future runs, so testing hasError first shows the old error instead of a spinner
Retry: assign a new future inside setState; FutureBuilder sees a different future and subscribes again
Past a demo: move the call into a repository behind a Bloc, a Riverpod FutureProvider or an AsyncNotifier, so caching, refresh and cancellation are not the widget's job
package:http does not throw on 4xx or 5xx; the code decodes whatever body came back and feeds it to User.fromJson. A 404 body like {"error":"not found"} then fails deep inside the model with a TypeError, far from the real cause.
**Check statusCode before decoding**: and turn non-2xx into typed errors such as NotFoundException and ServerException, which the UI can show properly
The body is not always JSON: a proxy or load balancer answers 502 with an HTML page, and jsonDecode throws a FormatException
No timeout: on a dead connection the call waits as long as the OS socket allows; add .timeout(...)
Dio behaves differently: by default it throws a DioException of type badResponse for any status outside 2xx, so the 404 never reaches the decoder, but you still have to map it
The term is interpolated into a URL string, so # starts a fragment and everything after it, & included, never reaches the server; it sees only q=C.
Build the Uri from parts: Uri.https('api.example.com', '/search', {'q': term}) percent-encodes every value, so a space becomes + and # becomes %23; a bare & ("Tom & Jerry") would start a new parameter instead
**With Dio, pass queryParameters:: never write the query into the path; it encodes values the same way
Do not pre-encode**: your own Uri.encodeComponent plus their escaping gives %2520, and double encoding is the other half of this bug
Path segments too: an id containing / silently changes the route, so escape it or build the Uri with pathSegments
Past a handful of endpoints, dio: interceptors, cancellation, multipart and a base URL come built in. For JSON, generate the code with json_serializable, or freezed when you also want immutability, copyWith and ==.
**http vs dio**: http is official and tiny but has no interceptors; fine until you need auth refresh, retries or cancellation
**Hand-written fromJson: no build step, fine for a few models, easy to get subtly wrong
Generated: a renamed field fails at build time instead of at runtime
The call path: build the request, check the status, decode into a model or map the failure into a typed error, then render loading, data or error from that one result
Keep Map<String, dynamic> out of the UI**: decode at the repository boundary, so widgets only ever see models
jsonDecode builds every JSON array as a List<dynamic>, so as List<Map<String, dynamic>> throws type 'List<dynamic>' is not a subtype of ... at runtime on every response, however valid the JSON is.
Why the cast fails: Dart generics are reified: as checks the list's own runtime type, not the types of its elements, and a decoded array is created as List<dynamic> even when every element is a map
Why the top level works: a decoded object is created as Map<String, dynamic>, so dio.get<Map<String, dynamic>> succeeds and only the nested list fails
The fix: cast the list to List<dynamic> and each element to Map<String, dynamic>; .cast<Map<String, dynamic>>() also works, but it wraps the list lazily and checks an element only when it is read
**Note on total**: fromJson already reads total as num; as double would throw for 10, because a JSON number without a fraction decodes as int
Catch it next time: the analyzer stays silent because res.data!['items'] is dynamic; a unit test that runs fromJson over a recorded response through jsonDecode fails at once, and json_serializable generates the per-element casts for you
Set all three: connectTimeout for opening the connection, sendTimeout for writing the body and receiveTimeout for the response. Dio has none by default, so a stalled request waits as long as the OS socket allows.
**connectTimeout: opening the TCP and TLS connection; 5-10 s
sendTimeout and receiveTimeout**: writing the body, and the wait for the first response byte and then between byte events. Receive is not a limit on the whole download: a server trickling one byte a second never trips it
None of them is a budget for the whole call: for a hard deadline wrap it in .timeout() and cancel the CancelToken from onTimeout, because .timeout abandons the future without closing the socket
Receive for ordinary JSON: 15-30 s, relaxed per request via Options for downloads and streams
In Dio, extend Interceptor and override onRequest, onResponse, onError; each calls handler.next, resolve or reject. It runs on every request, response and error, so auth headers, logging, retries, error mapping and token refresh live in one place.
Registering: add it to dio.interceptors; InterceptorsWrapper takes plain callbacks for small cases
Each handler decides: handler.next(...) continues, handler.resolve(response) short-circuits with success, handler.reject(err) fails
Order matters: they run in the order they were added, so put logging last if you want to see the auth header
**http has no such concept**: extend http.BaseClient (or wrap an inner http.Client) and override send
The refresh trap: retrying through the same Dio re-enters the interceptor, so a permanently bad token loops forever: flag the request in options.extra, and funnel concurrent 401s into one refresh
When there is no token, the callback never calls handler.next, so the request stops inside the interceptor chain and its future never completes. Every path through an interceptor must call next, resolve or reject exactly once.
Why it spins forever: the timeouts are applied by the adapter that sends the request, and this request never reaches it, so no timeout fires either
The fix: move handler.next(options) out of the if, so a request without a token still goes out and the server decides
A thrown exception is a different case: since dio 5.10 an exception inside an async onRequest becomes a failed request; only a path that returns without calling the handler hangs. On older dio it hangs too, so wrap the body in try and call handler.reject(DioException(requestOptions: options, error: e))
**Async work in onRequest**: the plain Interceptor lets requests through concurrently; use QueuedInterceptor when each request must wait for the previous one, for example during a token refresh
Map once, in one place, so HTTP never leaks into business logic: a DioException becomes a typed failure your repositories and view models can handle without knowing what a status code is.
The types you branch on: connectionTimeout, sendTimeout/receiveTimeout, badResponse (then read response?.statusCode), cancel, connectionError, badCertificate, transformTimeout (decoding the body took too long, dio 5.10+), unknown
A typical hierarchy: NetworkException for connectivity and timeouts, ServerException(statusCode) for 5xx, UnauthorizedException for 401, ForbiddenException for 403, NotFoundException, BadRequestException(message, fields) for a 4xx with a payload, and CancelledException
Where to do it: in the interceptor's onError, or in one helper every repository method goes through
Why it pays: the UI switches over a closed set of failures, and rules like "never retry a 403" are written once
Give the screen its own CancelToken, pass it to every request the screen starts, and call token.cancel() from dispose.
A cancelled call: completes with a DioException of type cancel; treat that as a normal outcome, never as an error on screen
Tokens are single-use: a cancelled one stays cancelled, so create a new one for the next request
**With package:http**: since 1.5 an AbortableRequest takes an abortTrigger future that aborts it; before that the only lever was close() on the Client, which kills everything it is running
Cancelling is a client-side act: the request may already have reached the server, so writes still need idempotency keys
Every keystroke starts a request, and whichever response arrives last wins: if the response for "p" is slower than the one for "phone", it overwrites the newer results and the list no longer matches the box.
Why it happens: responses do not come back in the order they were sent: a short query matches more rows and runs longer, a cache serves one call and not another, and the mobile network adds jitter. The mounted check only guards against a disposed widget, not against a stale answer
The fix: number every request and let only the newest one write the state; an older response that arrives later is dropped
Debounce and cancel on top: wait 300 ms after the last keystroke and cancel the previous call with a CancelToken to save traffic and server load; cancelling is best-effort (the response may already be in, or an inner layer may not pass the token), so keep the check
In state management: a Bloc with the restartable() transformer from bloc_concurrency, or switchMap in RxDart, keeps only the latest request for you
Catch it in a test: a fake API backed by Completers: complete the second call first, then the first, and assert the list shows the second query's results
A new client means a new connection pool, so every call repeats the DNS lookup and the TCP and TLS handshakes instead of reusing a socket that is already open.
What the pool is: Dio's adapter caches one dart:io HttpClient, and that client owns the keep-alive connections; one long-lived Dio is one pool
Why it costs 100-300 ms: Dio's adapter sets the client's idleTimeout to 3 seconds, and inside that window a second request to the same host reuses the connection and skips both handshakes
**Build one Dio per API at start-up: base URL plus its interceptors, and inject it where it is needed
Never close a shared client from a widget**: dio.close(force: true) aborts requests in flight; cancel with a CancelToken instead. The same goes for a package:http Client
When the pool is not enough: dart:io speaks HTTP/1.1 only, so for HTTP/2, system proxies and platform certificates swap in native_dio_adapter
Retry only what is safe to repeat, back off exponentially with jitter, and let the server's Retry-After override your own schedule.
What may be retried: GET, HEAD, PUT and DELETE are idempotent by contract; a POST only when it carries an idempotency key the backend deduplicates on, or a timed-out payment becomes two payments
Which failures: connectionError, the timeouts, 408, 429, 502, 503, 504. Never 400, 401, 403, 404 or 422: the identical request fails identically
The delay: 500 ms, then 1 s, at most three attempts in total, plus a cap on total time. Add jitter, or every phone that lost the same cell tower retries in lockstep
Where it lives: one Dio onError interceptor that re-sends with dio.fetch(err.requestOptions) and keeps the attempt count in options.extra; RetryClient for package:http
In exactly one layer: a retrying interceptor under a retrying repository under a "Try again" button is nine requests for one tap
All five 401s started their own refresh: the first one rotated the refresh token, and the other four presented a token the server had just revoked.
Single-flight refresh: hold one Future<String>? in the interceptor: the first 401 assigns it, every later 401 awaits that same future instead of calling the endpoint again
Check before refreshing: if the token has already changed since the failed request sent its header, just replay with the new one
Mark the replay: options.extra['retried'] = true, so a second 401 fails instead of looping forever
**Refresh through a separate Dio** with no auth interceptor, or the refresh call re-enters your own on its first 401; QueuedInterceptorsWrapper is dio's built-in take on the same idea
Only a 4xx from the refresh endpoint is a logout: a connectionError is not, or a tunnel ride signs the user out. Refresh proactively from the JWT's exp and most of these 401s never happen
Wrap each file in a MultipartFile, put the fields and files into FormData, and post that. dio does this with FormData, http with MultipartRequest; Dio's API is the ergonomic one.
**Never set Content-Type yourself**: the client writes multipart/form-data; boundary=..., and overwriting it breaks the boundary
Where the file comes from: image_picker for camera and gallery, file_picker for anything else; on web you get bytes rather than a path, so use MultipartFile.fromBytes
Big files: MultipartFile.fromFile already streams from disk, so the file never sits in memory; fromBytes holds it all. Use MultipartFile.fromStream(() => stream, length, filename: ...) only when the data is not a file on disk
Progress and cancelling: onSendProgress: (sent, total) drives the bar, and a CancelToken lets the user stop
Match the server's field names: and note that an S3 presigned PUT URL is not multipart at all: you PUT the raw bytes with the Content-Type that was signed
Stream the response into a temporary file, report progress from the received-bytes callback, and resume by asking the server for the remaining byte range.
Progress: dio.download(url, path, onReceiveProgress: (received, total) {...}); total is -1 when the server sends no Content-Length, so show an indeterminate bar. Throttle the UI to a few updates a second
Resume: send Range: bytes=<downloaded>- and append to the partial file; a server that supports it answers 206. A 200 means the range was ignored, so truncate and start over
Store the ETag with the partial file: a changed ETag means a different file on the server: discard and restart
**Write to <name>.part and rename only after the last byte: a crash then never leaves half a file that looks complete
Backgrounding stops a Dart download**: for downloads that must survive it, hand the job to background_downloader
Ask for cursor pagination: the server returns an opaque nextCursor and the client sends it back, so rows inserted or deleted between pages cannot repeat or skip items. Request the next page a few screens before the end, and never more than one at a time.
Offset (?page=3&limit=20) is simple and lets you jump to page N, but a new row at the top shifts every page: the user sees a duplicate, and a deletion silently skips an item
Cursor (?after=abc&limit=20) is stable under inserts, and nextCursor: null marks the end, so hasMore comes from the API instead of a guess from the page size
When to fetch: a few items before the end, one request at a time behind an isLoading guard
Merge carefully: dedupe by id, and show a failed page as a trailing "Retry" row instead of a full-screen error that hides what already loaded
**infinite_scroll_pagination**: if you would rather not own the paging state machine
Three layers, each right for a different case: HTTP cache headers, an app-level cache of parsed models, and an image cache.
HTTP headers: the server sends Cache-Control plus an ETag; next time the client sends If-None-Match and gets a 304 when nothing changed. Flutter has no browser cache, so dio_cache_interceptor does this job
App-level: keep the parsed model in a local database and use stale-while-revalidate: show the cached value at once, refresh in the background, swap it in
Images: cached_network_image adds a disk cache so pictures survive a restart
Do not cache: real-time data; subscribe to a stream instead
Two bugs to name: caching error responses, and serving one user's cached response to the next: never cache 4xx/5xx, key per-user responses by URL plus the signed-in user, and clear the cache on logout
connectivity_plus tells you which network interface is up, not whether the internet is reachable: Wi-Fi behind a captive portal reports wifi. Use it as a hint to show a banner or wake a sync, and treat a failed request as the real signal.
The stream: since version 6 onConnectivityChanged emits a List<ConnectivityResult>, because Wi-Fi and VPN can be active together; offline is a list containing none
Confirm reachability: a cheap HEAD to your own API, or internet_connection_checker_plus, before telling the user they are online
Do not block requests on the flag: it lags and lies; send the request and map connectionError and timeouts into an offline state
In the UI: keep showing cached data with an "Offline" banner instead of replacing the screen with an error, and retry queued work when connectivity returns
GraphQL is one endpoint where the client describes exactly the data it wants, so a screen gets no more and no less. It pays off when the backend already speaks it and screens need differently shaped, nested data.
The library: graphql_flutter: links for transport and auth, cache-first / network-first policies, and graphql_codegen for type-safe operations instead of hand-written fromJson
Do not add it: for a plain REST CRUD backend, or a two-endpoint app; and prefer REST for file uploads
Cache normalization: it keys on __typename plus id, so a query that omits id makes cache updates messy
Pagination: the Relay shape (edges, node, pageInfo) is the convention; design queries around it from day one
Subscriptions run over a WebSocket: with the same reconnect and auth caveats as any socket
Open the channel with web_socket_channel, assume it will drop, and reconnect with exponential backoff plus jitter; add a ping so a dead socket is noticed, and reopen it when the app resumes.
The API: WebSocketChannel.connect(uri), channel.stream for incoming events, channel.sink.add(...) to send, sink.close() to close. It is single-subscription, so wrap it in your own service if several widgets listen
Reconnect with backoff: Disconnected → Connecting → Connected, with exponential delays plus jitter and a cap; ping every 20-30 s and treat a missing pong as a disconnect, because a dead socket often looks alive
App lifecycle: close on paused and reconnect on resumed, or the OS kills the socket and you write into a broken pipe
Auth: the token goes in the connection URL or the first message; on refresh you reconnect, there is no rotating it in place
receiveTimeout measures the gap between byte events, not the length of the response, so a stream that stays silent longer than the timeout, for example while the model thinks, trips it. Disable it for this request, and parse the body line by line.
Read it as a stream: ResponseType.stream with Accept: text/event-stream, piped through utf8.decoder and LineSplitter
A chunk is not an event: one TCP chunk can hold three events or half of one, and a multi-byte character can be split across two; the decoder survives that, a hand-rolled split does not
The wire format: lines of data:, event:, id: and retry:; a blank line dispatches the accumulated event, and a line starting with : is the comment most servers use as a heartbeat
Stay connected: receiveTimeout: Duration.zero on this request's Options only, remember the last id: and send it as Last-Event-ID when you reconnect, and cancel the CancelToken in dispose
If it looks frozen: a buffering proxy can hold everything until its own buffer fills; on web use the browser's EventSource, which reconnects for you but cannot send an Authorization header
Bound how many requests are in flight and collect a per-item outcome, so the pool is never flooded and one failure cannot throw away 199 good results.
**Future.wait starts everything first**: building the list fires all 200 requests; wait only observes them
And it loses the successes: by default it waits for all, then completes with the first error and drops every value it collected; with eagerError: true it throws at once while the other 199 keep burning battery
Bounded concurrency: 4-8 at a time, through Pool(6).withResource(...) or a worker loop over a shared queue; that is what a rate limiter, the keep-alive pool and a phone radio all tolerate
Keep the partial results: Dart 3's futures.wait throws a ParallelWaitError whose values and errors are indexed per future, so you can render what arrived and retry only the rest
**One CancelToken for the whole fan-out**: checked between items, so leaving the screen drains the queue instead of finishing it
Then name the real issue: 200 round trips is a design problem, ask for a batch endpoint.
Treat the payload as untrusted input, and make the data layer the only place where a contract change is allowed to fail.
A new field must be ignored: map-based parsing does that for free, so do not add a strict decoder or an assertion over key counts
An unknown enum value must fall back, not throw: unknownEnumValue: in json_serializable plus an unknown member; a hand-written parser needs the same default in its switch
A field turning null must not crash a widget three layers away: model it nullable, decide the fallback while mapping to the domain entity, and never use ! on decoded JSON
Parse defensively at the boundary: small typed readers that check the runtime type and return a default, instead of a bare as String; a number arriving as a string is the most common surprise. Turn parse failures into your typed errors, not an exception inside build()
Keep real captured payloads as fixtures: that is what turns a backend change into a red CI run instead of an incident. Be a tolerant reader and a strict writer, and still ask for versioned endpoints