Flutter / Asynchronous application boundaries / Lesson 11
Local storage and offline states
Design cache and draft policies explicitly, and distinguish persisted bytes from authoritative business success.
On this page
Lesson goals
- Name what is being persisted
- Separate freshness from availability
- Encode a versioned draft envelope
Name what is being persisted
A cached response, an unsaved draft, an authenticated credential, and a confirmed transaction have different requirements. A generic save method should not hide these distinctions. A cache can often be discarded and reconstructed, while losing a draft may destroy user work. A transaction may require durable server confirmation and reconciliation after uncertain completion. Define the owner, expected lifetime, sensitivity, schema version, and deletion policy for each local record. Plugin storage APIs vary by platform and package; choose them from documented guarantees rather than assuming a successful write call provides every form of durability.
Separate freshness from availability
A cached value can be available but stale. Store enough metadata to explain that state, such as the last successful fetch time and schema version, without presenting a local timestamp as proof of server truth. Choose whether opening a screen shows cache immediately and refreshes, requires a fresh response, or allows an explicit offline mode. The right policy depends on the consequence of outdated information. A reading catalog and a seat reservation do not have identical freshness needs. Avoid universal retry intervals or expiry durations detached from the application contract.
Encode a versioned draft envelope
The first fragment produces a small JSON envelope for a workshop draft. A version marker lets a future reader identify the expected schema before interpreting fields. The saved title remains a draft; encoding it does not establish validation, submission, encryption, or durable disk storage. Keep transport formats explicit so tests can check compatibility. A production storage adapter should handle write failures and preserve the previous valid record where its documented replacement semantics permit. Do not store credentials in an ordinary preferences file merely because the API accepts strings.
import 'dart:convert';
String encodeDraft(String title) => jsonEncode({
'schema': 1,
'title': title,
});
void main() { print(encodeDraft('Maps')); }
{"schema":1,"title":"Maps"}
The result is serialized text only. No filesystem, preferences plugin, or database write is performed.
Reject incompatible cached shapes explicitly
The second fragment checks the envelope version and title type. It returns absence for an incompatible draft instead of crashing a widget during rendering. The surrounding repository should decide whether to quarantine, migrate, or discard that record and whether the user needs a recovery message. A migration must preserve meaning, not just rename a key until decoding stops failing. Keep a malformed-cache fixture and an old-version fixture in tests. If a draft is important user work, silent deletion is usually less appropriate than a visible recovery path.
String? decodeDraft(String source) {
try {
final raw = jsonDecode(source);
if (raw is! Map<String, dynamic> || raw['schema'] != 1) return null;
final title = raw['title'];
return title is String ? title : null;
} on FormatException { return null; }
}
// A schema-1 Maps fixture returns Maps; schema 2 returns null.
The schema-1 fixture returns Maps; the unsupported schema returns null.
This depends on dart:convert. Returning null signals the repository to apply its recovery policy, not to announce a successful empty draft.
Treat queued writes as unresolved commands
An offline command queue needs stable operation identity, ordering rules, conflict handling, and a clear distinction between pending and confirmed. Connectivity status is only a hint: a device can have a network interface while the target service remains unreachable. A timed-out write may already have reached the server, so retry requires the server’s idempotency contract or reconciliation. Do not mark a reservation confirmed solely because it was placed into a local queue. Show the pending state honestly and let the user understand whether cancellation is still possible.
Test recovery instead of only the happy write
Use an in-memory storage fake to simulate read failure, malformed bytes, unsupported versions, and write rejection without touching a real device database. Verify the chosen UI state preserves recoverable drafts and distinguishes stale data from fresh data. For queued commands, simulate restart between local enqueue and remote confirmation and inspect duplicate handling. These pure tests establish policy, while actual platform persistence, process termination, and protected-storage behavior require target integration tests. Document that boundary rather than treating successful JSON round trips as proof of durable offline operation.
Common errors and fixes
Old cached data appears fresh
- Why it happens
- Availability and successful refresh were represented by the same flag.
- How to fix it
- Expose freshness metadata and explicit refresh outcomes.
- Prevent it next time
- Test stale cache with a failing refresh.
An app update cannot decode saved drafts
- Why it happens
- The schema changed without a version or recovery policy.
- How to fix it
- Read a versioned envelope and migrate or recover deliberately.
- Prevent it next time
- Retain fixtures from supported prior schemas.
Retry creates duplicate remote operations
- Why it happens
- Uncertain completion was treated as definite failure.
- How to fix it
- Use operation identity and the authoritative service’s idempotency or reconciliation contract.
- Prevent it next time
- Test lost responses separately from rejected writes.
Check your understanding
1. Does successful encodeDraft prove the draft will survive process termination?
Show worked solution
No. It only constructs a string. Durability depends on the storage adapter, write completion semantics, platform behavior, and failure recovery. Test serialization separately from actual target persistence and state exactly which guarantee each check establishes.
2. How should an offline seat reservation be labelled before server confirmation?
Show worked solution
Use a pending or queued label with any relevant cancellation limitations. Preserve an operation identifier for reconciliation. Availability may change before the server evaluates the command, so local enqueue is not confirmation and must not promise a seat.
Key takeaways
- Name what is being persisted
- Test recovery instead of only the happy write