Flutter caching strategies for local cache, stale data, and refresh rules: what to store, when to invalidate, and how to keep screens consistent.

Caching in a mobile app means keeping a copy of data nearby (in memory or on the device) so the next screen can render instantly instead of waiting on the network. That data might be a list of items, a user profile, or search results.
The hard part is that cached data is often slightly wrong. Users notice quickly: a price that doesn’t update, a badge count that feels stuck, or a details screen that shows old info right after they changed it. What makes this painful to debug is timing. The same endpoint can look fine after pull-to-refresh, but wrong after back navigation, app resume, or switching accounts.
There’s a real tradeoff. If you always fetch fresh data, screens feel slow and jumpy, and you waste battery and data. If you cache aggressively, the app feels fast, but people stop trusting what they see.
A simple goal helps: make freshness predictable. Decide what each screen is allowed to show (fresh, slightly stale, or offline), how long data can live before you refresh it, and which events must invalidate it.
Picture a common flow: a user opens an order, then goes back to the order list. If the list comes from cache, it might still show the old status. If you refresh every time, the list may flicker and feel slow. Clear rules like “show cached instantly, refresh in the background, and update both screens when the response arrives” make the experience consistent across navigation.
A cache isn’t just “saved data.” It’s a saved copy plus a rule for when that copy is still valid. If you store the payload but skip the rule, you end up with two versions of reality: one screen shows new info, another shows yesterday’s.
A practical model is to put every cached item into one of three states:
This framing keeps your UI predictable because it can respond the same way every time it sees a given state.
Freshness rules should be based on signals you can explain to a teammate. Common choices are a time-based expiry (like 5 minutes), a version change (schema or app version), a user action (pull to refresh, submit, delete), or a server hint (ETag, last-updated timestamp, or an explicit “cache invalid” response).
Example: a profile screen loads cached user data instantly. If it’s stale-but-usable, it shows the cached name and avatar, then quietly refreshes. If the user just edited their profile, that’s a must-refresh moment. The app should update the cache immediately so every screen stays consistent.
Decide who owns these rules. In most apps, the best default is: the data layer owns freshness and invalidation, the UI just reacts (show cached, show loading, show error), and the backend provides hints when it can. This prevents each screen from inventing its own rules.
Good caching starts with one question: if this data is a little old, will it hurt the user? If the answer is “probably fine,” it’s usually a good fit for local caching.
Data that’s read a lot and changes slowly is typically worth caching: feeds and lists people scroll often, catalog-style content (products, articles, templates), and reference data like categories or countries. Settings and preferences also belong here, along with basic profile info like name and avatar URL.
The risky side is anything money-related or time-critical. Balances, payment status, stock availability, appointment slots, delivery ETAs, and “last seen online” can cause real problems if they’re stale. You can still cache them for speed, but treat the cache as a temporary placeholder and force a refresh at decision points (for example, right before confirming an order).
Derived UI state is its own category. Saving the selected tab, filters, search query, sort order, or scroll position can make navigation feel smooth. It can also confuse people when old choices reappear unexpectedly. A simple rule works well: keep UI state in memory while the user stays in that flow, but reset it when they intentionally “start over” (like returning to the home screen).
Avoid caching data that creates security or privacy risk: secrets (passwords, API keys), one-time tokens (OTP codes, password reset tokens), and sensitive personal data unless you truly need offline access. Never cache full card details or anything that increases fraud risk.
In a shopping app, caching the product list is a big win. The checkout screen, though, should always refresh totals and availability right before purchase.
Most Flutter apps end up needing a local cache so screens load fast and don’t flash empty while the network wakes up. The key decision is where cached data lives, because each layer has different speed, size limits, and cleanup behavior.
A memory cache is the fastest. It’s great for data you just fetched and will reuse while the app stays open, like the current user profile, the last search results, or a product the user just viewed. The tradeoff is straightforward: it disappears when the app is killed, so it won’t help with cold starts or offline use.
Disk key-value storage fits small items you want across restarts. Think preferences and simple blobs: feature flags, “last selected tab,” and small JSON responses that rarely change. Keep it intentionally small. Once you start dropping large lists into key-value storage, updates get messy and bloat becomes easy.
A local database is best when your data is larger, structured, or needs offline behavior. It also helps when you need queries (“all unread messages,” “items in cart,” “orders from last month”) instead of loading one giant blob and filtering in memory.
To keep caching predictable, pick one primary store for each type of data and avoid keeping the same dataset in three places.
A quick rule of thumb:
Also plan for size. Decide what “too big” means, how long you keep items, and how you clean up. For example: cap cached search results to the last 20 queries, and regularly remove records older than 30 days so the cache doesn’t quietly grow forever.
Refresh rules should be simple enough that you can explain them in one sentence per screen. That’s where sensible caching pays off: users get fast screens, and the app stays trustworthy.
The simplest rule is TTL (time to live). Store data with a timestamp and treat it as fresh for, say, 5 minutes. After that, it becomes stale. TTL works well for “nice to have” data like a feed, categories, or recommendations.
A helpful refinement is splitting TTL into soft TTL and hard TTL.
With a soft TTL, you show cached data immediately, then refresh in the background and update the UI if it changed. With a hard TTL, you stop showing old data once it expires. You either block with a loader or show an “offline/try again” state. Hard TTL fits cases where being wrong is worse than being slow, like balances, order status, or permissions.
If your backend supports it, prefer “refresh only when changed” using an ETag, updatedAt, or version field. Your app can ask “has this changed?” and skip downloading the full payload when nothing is new.
A user-friendly default for many screens is stale-while-revalidate: show now, refresh quietly, and redraw only if the result differs. It gives speed without random flicker.
Per-screen freshness often ends up like this:
Pick rules based on the cost of being wrong, not just the cost of fetching.
Cache invalidation starts with one question: what event makes cached data less trustworthy than the cost of refetching it? If you choose a small set of triggers and stick to them, behavior stays predictable and the UI feels steady.
Triggers that matter most in real apps:
Example: a user edits their profile photo, then goes back. If you rely only on time-based refresh, the previous screen may show the old image until the next fetch. Instead, treat the edit as the trigger: update the cached profile object right away and mark it fresh with a new timestamp.
Keep invalidation rules small and explicit. If you can’t point to the exact event that invalidates a cache entry, you’ll refresh too often (slow, jumpy UI) or not enough (stale screens).
Start by listing your key screens and the data each one needs. Don’t think in endpoints. Think in user-visible objects: profile, cart, order list, catalog item, unread count.
Next, pick one source of truth per data type. In Flutter, this is usually a repository that hides where data comes from (memory, disk, network). Screens shouldn’t decide when to hit the network. They should ask the repository for data and react to the returned state.
A practical flow:
Metadata is what makes rules enforceable. If ownerUserId changes (logout/login), you can drop or ignore old cached rows immediately instead of showing the previous user’s data for a split second.
For UI behavior, decide up front what “stale” means. A common rule: show stale data instantly so the screen isn’t blank, kick off a refresh in the background, and update when new data arrives. If refresh fails, keep the stale data visible and show a small, clear error.
Then lock the rules with a few boring tests:
That’s the difference between “we have caching” and “our app behaves the same every time.”
Nothing breaks trust faster than seeing one value on a list screen, tapping into details, editing it, then going back and seeing the old value again. Consistency across navigation comes from making every screen read from the same source.
A solid rule is: fetch once, store once, render many times. Screens shouldn’t call the same endpoint independently and keep private copies. Put cached data in a shared store (your state management layer), and let both list and detail screens watch the same data.
Keep a single place that owns the current value and freshness. Screens can request a refresh, but they shouldn’t each manage their own timers, retries, and parsing.
Practical habits that prevent “two versions of reality”:
Even with good rules, users will sometimes see stale data (offline, slow network, backgrounded app). Make it obvious with small, calm signals: an “Updated just now” timestamp, a subtle “Refreshing…” indicator, or an “Offline” badge.
For edits, optimistic updates often feel best. Example: a user changes a product price on the detail screen. Update the shared store right away so the list screen shows the new price when they go back. If the save fails, roll back to the previous value and show a short error.
Most caching failures are boring: the cache works, but nobody can explain when it should be used, when it expires, and who owns it.
The first trap is caching without metadata. If you store only the payload, you can’t tell whether it’s old, which app version produced it, or which user it belongs to. Save at least savedAt, a simple version number, and a userId (or tenant key). That one habit prevents a lot of “why is this screen wrong?” bugs.
Another common issue is multiple caches for the same data with no owner. A list screen keeps an in-memory list, a repository writes to disk, and a details screen fetches again and saves elsewhere. Pick one source of truth (often the repository layer) and make every screen read through it.
Account changes are a frequent footgun. If someone logs out or switches accounts, clear user-scoped tables and keys. Otherwise you can show the previous user’s profile photo or orders for a split second, which feels like a privacy breach.
Practical fixes that cover the issues above:
Example: your product list loads instantly from cache, then refreshes quietly. If refresh fails, keep showing cached data but make it clear it may be out of date and offer Retry. Don’t block the UI on refresh when cached data would be fine.
Before release, turn caching from “it seems fine” into rules you can test. Users should see data that makes sense even after navigating back and forth, going offline, or signing in with a different account.
For every screen, decide how long data can be considered fresh. It might be minutes for fast-moving data (messages, balances) or hours for slow-changing data (settings, product categories). Then confirm what happens when it’s not fresh: background refresh, refresh on open, or manual pull-to-refresh.
For each data type, decide which events must wipe or bypass the cache. Common triggers include logout, editing the item, switching accounts, and app updates that change the data shape.
Make sure cached entries store a small set of metadata next to the payload:
Keep ownership clear: use one repository per data type (for example, ProductsRepository), not per widget. Widgets should ask for data, not decide cache rules.
Also decide and test offline behavior. Confirm what screens show from cache, which actions are disabled, and what copy you display (“Showing saved data,” plus a visible refresh control). Manual refresh should exist on every cache-backed screen and be easy to find.
Imagine a simple shop app with three screens: a product catalog (list), product details, and a Favorites tab. Users scroll the catalog, open a product, and tap a heart icon to favorite it. The goal is to feel fast, even on slow networks, without showing confusing mismatches.
Cache locally what helps you render instantly: catalog pages (IDs, title, price, thumbnail URL, favorite flag), product details (description, specs, availability, lastUpdated), image metadata (URLs, sizes, cache keys), and the user’s favorites (a set of product IDs, optionally with timestamps).
When the user opens the catalog, show cached results immediately, then revalidate in the background. If fresh data arrives, update only what changed and keep scroll position stable.
For the favorite toggle, treat it as a “must be consistent” action. Update the local favorites set right away (optimistic update), then update any cached product rows and cached product details for that ID. If the network call fails, roll back and show a small message.
To keep navigation consistent, drive both list badges and the details heart icon from the same source of truth (your local cache or store), not from separate screen state. The list heart updates as soon as you return from details, the details screen reflects changes made from the list, and the Favorites tab count matches everywhere without waiting for a refetch.
Add simple refresh rules: catalog cache expires quickly (minutes), product details a bit longer, and favorites never expire but always reconcile after login/logout.
Caching stops being mysterious when your team can point to one page of rules and agree on what should happen. The goal isn’t perfection. It’s predictable behavior that stays the same across releases.
Write a tiny table per screen and keep it short enough to review during changes: screen name and main data, cache location and key, freshness rule (TTL, event-based, or manual), invalidation triggers, and what the user sees while refreshing.
Add lightweight logging while you tune. Record cache hits, misses, and why a refresh happened (TTL expired, user pulled to refresh, app resumed, mutation completed). When someone reports “this list feels wrong,” those logs make the bug solvable.
Start with simple TTLs, then refine based on what users notice. A news feed might accept 5 to 10 minutes of staleness, while an order status screen might need a refresh on resume and after any checkout action.
If you’re building a Flutter app quickly, it can help to outline your data layer and cache rules before you implement anything. For teams using Koder.ai (koder.ai), planning mode is a practical place to write those per-screen rules first, then build to match them.
When you’re tuning refresh behavior, protect stable screens while you experiment. Snapshots and rollback can save time when a new rule accidentally introduces flicker, empty states, or inconsistent counts across navigation.
Start with one clear rule per screen: what it may show immediately (cached), when it must refresh, and what the user sees during refresh. If you can’t explain the rule in one sentence, the app will eventually feel inconsistent.
Treat cached data as having a freshness state. If it’s fresh, show it. If it’s stale but usable, show it now and refresh quietly. If it’s must refresh, fetch before showing (or show a loading/offline state). This keeps your UI behavior consistent instead of “sometimes it updates, sometimes it doesn’t.”
Cache things that are read often and can be slightly old without harming the user, like feeds, catalogs, reference data, and basic profile info. Be careful with money- or time-critical data like balances, stock, ETAs, and order status; you can cache it for speed, but force a refresh right before a decision or confirmation step.
Use memory for fast reuse during the current session, like the current profile or recently viewed items. Use disk key-value storage for small, simple items that should survive restarts, like preferences. Use a database when data is large, structured, needs queries, or should work offline, like messages, orders, or an inventory list.
A plain TTL is a good default: consider data fresh for a set time, then refresh. For many screens, a better experience is “show cached now, refresh in the background, then update if changed,” because it avoids blank screens and reduces flicker.
Invalidate on events that clearly change trust in the cache: user edits (create/update/delete), login/logout or account switching, app resume if data is older than your TTL, and explicit user refresh. Keep these triggers small and explicit so you don’t end up refreshing constantly or never refreshing when it matters.
Make both screens read from the same source of truth, not their own private copies. When the user edits something on the details screen, update the shared cached object immediately so the list renders the new value on back navigation, then sync with the server and roll back only if the save fails.
Always store metadata next to the payload, especially a timestamp and a user identifier. On logout or account switch, clear or isolate user-scoped cache entries immediately and cancel in-flight requests tied to the old user so you don’t briefly render the previous user’s data.
Default to keeping stale data visible and show a small, clear error state that offers retry, rather than blanking the screen. If the screen can’t safely show old data, switch to a must-refresh rule and show a loading or offline message instead of pretending the stale value is trustworthy.
Put cache rules in your data layer (for example, repositories) so every screen follows the same behavior. If you’re building quickly in Koder.ai, write the per-screen freshness and invalidation rules in planning mode first, then implement so the UI simply reacts to states instead of inventing its own refresh logic.