ATL-2026-010architectureexperimental

Cached data is not necessarily current data. What should a repository report?

The catalog is already visible, but its refresh fails. A Dart/Flutter experiment separates content, confirmation age, network failures and persistence failures, while Ark MVP carries those distinctions into screen state.

Published
September 23, 2026
Verified
September 18, 2026

Cached data is not necessarily current data. What should a repository report?

A catalog opens. The saved list appears immediately, with a refresh indicator beside it. A few seconds later, the network request fails. The list disappears, replaced by “Could not load data” and a retry button.

But the data had already loaded. The user had seen it and might even have found the item they needed. A different operation failed: fetching a newer value. Why should that failure erase the useful result of an earlier operation?

Keeping the list without a warning is not entirely honest either. The screen looks healthy, although the application does not know whether the server-side catalog has changed. A “from cache” badge explains the origin, but says nothing about freshness.

The problem starts before the widget handles an error. It starts with the contract through which the application receives data.

The list exists. The refresh failed

The component that coordinates local storage and a server is commonly called a repository. This is a data-access boundary, not a Git repository. The rest of the application should not have to select a source, read a saved record and reconcile it with a network response.

A method such as Future<List<String>> loadCatalog() is sufficient for a single read. It returns a list or completes with an error. The screen above asks several questions at once, though: what can it show now, is a refresh running, how did the last attempt end, and was the received value saved on the device?

A single read result does not distinguish these facts, especially when every exception becomes a generic error state that replaces the list.

Flutter’s offline-first guide describes several approaches: network access with local fallback, streams emitting local and remote values, and local reads with separate synchronization. Requirements determine the choice. The delivery mechanism alone does not establish what a failed refresh means for content already on screen.

For this experiment, ArkTelos Lab built a small catalog with controlled sources. It represents the available content separately from the attempt to update it:

final class CatalogState {
  const CatalogState({
    this.data,
    this.refresh = RefreshStatus.idle,
    this.problem,
    this.persistence = Persistence.absent,
    this.cacheRead = CacheRead.unread,
    this.freshness = Freshness.unknown,
  });

  final CatalogData? data;
  final RefreshStatus refresh;
  final RefreshProblem? problem;
  final Persistence persistence;
  final CacheRead cacheRead;
  final Freshness freshness;
}

data is the available content. refresh describes the refresh operation; problem retains the kind of failure. persistence concerns saving the current value on the device, while cacheRead records the outcome of reading local storage. freshness is an age assessment under a chosen policy, discussed below.

These are not six unrelated switches for a widget to assemble. The repository produces a complete snapshot with a consistent combination. A list alongside refresh == failed is valid: content exists, but the latest attempt to update it failed.

An empty list is content too. data == null means no value is available; data.items.isEmpty means an empty catalog was obtained. Calling both “no data” loses the distinction between an unknown answer and a known absence of items.

Origin does not establish freshness

The catalog carries its origin and the time of its last successful confirmation:

final class CatalogData {
  CatalogData(List<String> items, this.origin, this.confirmedAt)
      : items = List.unmodifiable(items);

  final List<String> items;
  final Origin origin;
  final DateTime? confirmedAt;
}

origin is either cache or remote. It describes where this instance obtained the value, not its quality. A local record may have been confirmed ten seconds ago. A network response retained in memory still has a remote origin an hour later, without remaining proof of current server contents.

The example uses a five-minute TTL: a permitted age for the confirmation. Before the boundary the value meets the policy; at the boundary it is already expired:

Freshness classify(
  DateTime? confirmedAt,
  DateTime now,
  bool clockUncertain,
) {
  if (confirmedAt == null || clockUncertain) {
    return Freshness.unknown;
  }
  final age = now.difference(confirmedAt);
  if (age.isNegative) return Freshness.unknown;
  return age < ttl ? Freshness.withinTtl : Freshness.expired;
}

withinTtl deliberately does not mean “definitely up to date.” The server might change immediately after responding. A real API might itself return cached content, so a successful HTTP response does not necessarily establish when that content was confirmed. The source contract must define that meaning.

The test source is simpler: a successful response counts as a new observation, and a controlled clock supplies its receipt time. This is a fixture assumption, not a property of every backend.

A missing or future confirmation time produces unknown. The repository also detects the clock moving backwards: even if the timestamp remains in the past, the previous age calculation can no longer be trusted. In the example, uncertainty persists until the next successful response. This does not detect every possible clock manipulation or validate all stored metadata.

Age changes must also reach the screen. The list may remain unchanged as its TTL expires. The repository exposes recheckTime(), which its caller must invoke on resume or through a timer. There is no hidden timer in the example. Without that call, another minute passing does not itself notify the UI.

The network succeeded. The write did not

Another boundary appears after a successful refresh. The new catalog has arrived, but saving it locally fails. If fetching and saving share one broad catch, the application can easily call this a network failure and return the old cache.

It would discard a newer value while concealing the actual problem.

The fixture accepts the response into memory first, then saves it separately. During the write, state contains the new list and Persistence.pending; afterwards, it contains saved or failed. The relevant implementation is:

try {
  await local.write(data);
  if (_active(generation)) {
    _publish(
      data: data,
      refresh: RefreshStatus.idle,
      persistence: Persistence.saved,
    );
  }
} catch (_) {
  if (_active(generation)) {
    _publish(
      data: data,
      refresh: RefreshStatus.idle,
      persistence: Persistence.failed,
    );
  }
}

Here, data is the accepted network response, _publish creates a complete snapshot, and generation identifies the repository’s current cycle. _active prevents old work from publishing state after its owner closes. It does not physically cancel a request or write.

This exception handler concerns persistence only. Fetching has separate error handling. The example groups write failures into one category; a production implementation would need diagnostics and more precise causes.

The trade-off is explicit: if the process ends before the write succeeds, the new value may not survive a restart. The screen can report “Updated, but not saved on this device,” rather than promise offline availability. A product that requires durable storage before display needs a different order.

The confirmation timestamp is recorded when the source responds and saved with that response. A later write completion does not make the catalog younger.

Responses arriving in the wrong order

Local reads are usually considered fast. That is not a guarantee that they finish before a network request. A delayed read can return old content after the application has already accepted a newer response.

In this experiment, cache reading and network access start independently. A cache result has limited authority to replace content: once a network result has been accepted, a late local result is ignored. A test completes the network with new, then the cache with old; the current value remains new.

Concurrent refresh calls require a different rule. A second call joins the active cycle and receives the same Future, rather than starting another request. The test checks both future identity and a single source invocation.

There is a limitation: that shared cycle also waits for cache reading and persistence. If an adapter hangs forever, completion never arrives. Adapters must impose finite timeouts; joining requests is not cancellation or timeout handling.

A corrupt cache is not an empty catalog, either. A read failure is recorded separately, the invalid value is not published, and network access continues. Otherwise, a broken record could masquerade as the legitimate business answer “no items.”

Not every failure permits cached content

So far, the network failure meant an update could not be fetched, with no known prohibition on using existing content. An access denial means something different.

If the source explicitly forbids access to the catalog, “show the cache on any error” contradicts that decision. In the example, AccessDenied clears visible content and prevents this repository instance from accepting a local value. A late cache read cannot restore the list. Neither can a subsequent transport failure; a successful authorized network read can supply new content.

This is a narrow behavior check, not a complete authorization model. The fixture does not erase the disk record or prove protection after a restart. Real applications need rules for storage, account changes and the lifetime of saved permissions. The distinction here is between an unavailable network and a known prohibition.

Passing the facts to the screen

The repository has not chosen warning text or its placement. It reports content, refresh outcome and persistence state. Presentation turns those facts into what a person should see.

An adapter over a familiar state-management solution can implement this boundary. The fixture uses ark_mvp 1.1.0 and ark_mvp_flutter 1.1.0. They do not calculate TTL or reconcile cache and server responses. The repository has already done that work.

ModelBinding connects the complete snapshot to presentation:

binding = ModelBinding(
  read: () => widget.repository.state,
  changes: [widget.repository.changes],
);

A change signal means the snapshot must be read again. A Presenter—an object that converts model data into screen state—then selects the label, loading flag and displayed content. Here it extends FlutterPresenter, and an ordinary Flutter widget receives its output through MvpView.

“Could not refresh” remains part of screen state, rather than a one-off toast. Otherwise the warning would disappear when the screen reopened, although the failed refresh still matters. This continues the distinction between state, result and effect: an important qualification of displayed data should remain available on subsequent reads.

The widget test shows cached content while a request runs, then completes that request with a transport failure. The list must stay, the indicator must disappear, and a warning must appear. Removing and recreating the screen with the same repository must restore both list and warning. The test passes without replaying a one-off effect.

Two more widget tests distinguish an empty catalog from unavailable data and verify new content alongside a persistence warning. The application retains repository ownership when the screen closes; the Flutter adapter does not acquire the right to dispose its sources.

What the checks establish

Sixteen repository checks and three widget tests passed on Flutter 3.44.7 and Dart 3.12.2; the analyzer reported no issues. Sources use controlled Completer objects, and tests set the clock. There are no real network requests, five-minute waits or performance measurements. Published Ark MVP versions were used, not locally modified packages.

These checks do not prove a particular database reliable, establish behavior on a phone or cover every concurrent sequence. They establish a narrower result: in the tested cases, transport failure preserves content, persistence has its own outcome, TTL is separate from origin, and a late cache read cannot undo an accepted decision.

The useful review question is therefore not simply “does this repository have a cache?” It is “which facts are lost between the source and the screen?” Can the caller distinguish an empty response from no response, a failed refresh from missing data, an old confirmation from a recent disk write, and a storage failure from a network failure?

If those distinctions disappear from the contract, widgets have to reconstruct them by guessing. Another state manager cannot recover information it never received.

The ArkTelos Lab experiment source and checks let you reproduce these transitions with controlled sources and inspect the presentation adapter separately from the data contract.


ArkTelos: official website · news EN · новости RU

ArkTelos Lab: laboratory · Telegram EN · Telegram RU

Result boundaries

  • Educational catalog with controlled sources and clock. Sixteen repository checks and three widget tests, one full run. No real network, database, device, pixel checks or benchmark. TTL does not guarantee server freshness; access denial is checked within one instance, not after restart. Publication preparation did not rerun the experiment.

CODE / DATA / AGENTS

Related experiments

experimentcache-freshness-contract

Check cache age, refresh and persistence outcomes, response ordering and screen-state restoration.

Educational catalog with controlled sources and clock. Sixteen repository checks and three widget tests, one full run. No real network, database, device, pixel checks or benchmark. TTL does not guarantee server freshness; access denial is checked within one instance, not after restart. Publication preparation did not rerun the experiment.

Open the experiment record →

Telegram edition

Read in ArkTelos Lab

Read in ArkTelos Lab ↗

ARKTELOS CHANNELS

News and engineering material, without mixing languages.

The main channels cover ArkTelos development. ArkTelos Lab publishes architecture analysis, experiments, and reproducible research.