← Back to blog
Mastering Riverpod: Core Concepts · Part 4 of 8
August 19, 20267 min read

keepAlive — When You Don't Want Your Provider to Die

RiverpodFlutterDart

keepAlive

This is Part 4 of Mastering Riverpod: Core Concepts. autoDispose is all-or-nothing: a provider either lives forever or dies the moment it's unwatched. But real caching is more nuanced — "auto-dispose this, but keep it alive once the fetch succeeds" or "keep it for 5 minutes, then let it go." That fine-grained control comes from ref.keepAlive(), and it turns autoDispose from a switch into a dial.


The problem: autoDispose is too aggressive sometimes

autoDispose (Part 3) is great for cleanup, but pure auto-dispose can over-fetch. Imagine a detail screen with an autoDispose provider: navigate away and back, and it refetches every time — even if nothing changed. You wanted cleanup for unused data, but caching for data you just loaded.

What you really want is conditional: "dispose if I never got the data, but if the fetch succeeded, hang onto it." ref.keepAlive() expresses exactly that.


Inside an autoDispose provider, calling ref.keepAlive() immediately stops the provider from being auto-disposed. It returns a KeepAliveLink — a handle you can later use to re-enable disposal:

final link = ref.keepAlive();  // stop auto-disposal now
// ...later...
link.close();                  // re-enable auto-disposal

So keepAlive() is "pin this in memory," and link.close() is "unpin it (resume normal auto-dispose)." This pair is the building block for every caching policy below.

keepAlive() only makes sense inside an autoDispose provider — there's nothing to prevent on a keep-alive-by-default provider. (riverpod_lint has a rule about this.)


Pattern 1: keep alive only after a successful fetch

The most common use. Fetch the data; if it succeeds, keep it cached; if it fails, let it be disposed so the next visit retries cleanly:

final userProvider = FutureProvider.autoDispose<User>((ref) async {
  final user = await ref.watch(userRepositoryProvider).fetchUser();
  ref.keepAlive(); // reached only if the await succeeded → cache the result
  return user;
});

If fetchUser() throws, the ref.keepAlive() line is never reached, so the failed provider stays auto-dispose and is torn down when unwatched — a fresh attempt next time. If it succeeds, the result is pinned and survives navigation. This "cache successes, discard failures" behavior is exactly what you want for detail screens, and it's just two lines.


Pattern 2: cache for a duration (time-based)

Sometimes you want data cached for a window — say 5 minutes — then refreshed. Combine keepAlive() (pin now), a Timer (unpin later), and ref.onDispose (cancel the timer if the provider is destroyed first). This is clean enough to package as a reusable extension on Ref:

import 'dart:async';

extension CacheForExtension on Ref {
  /// Keep the provider alive for [duration], then re-enable auto-disposal.
  void cacheFor(Duration duration) {
    final link = keepAlive();                 // pin
    final timer = Timer(duration, link.close); // unpin after `duration`
    onDispose(timer.cancel);                   // tidy the timer if disposed early
  }
}

// Usage: cache this fetch for 5 minutes.
final dashboardProvider = FutureProvider.autoDispose<Dashboard>((ref) async {
  ref.cacheFor(const Duration(minutes: 5));
  return ref.watch(dashboardRepoProvider).fetch();
});

Now dashboardProvider is cached for 5 minutes (no refetch on revisit within the window), then auto-disposes so a later visit gets fresh data. That's a real-world caching policy in a handful of lines — the kind of thing that's painful to hand-roll without Riverpod.


keepAlive vs plain keep-alive (no autoDispose)

It's worth distinguishing two ways to "keep state":

| Approach | Behavior | Use when | | --- | --- | --- | | No autoDispose (default) | cached forever (until ProviderScope disposed) | genuinely app-wide state (auth, theme, DI services) | | autoDispose + ref.keepAlive() | auto-dispose unless/until you decide to pin (and you can unpin) | conditional caching: keep-after-success, cache-for-duration, keep-while-condition |

The difference is control. Plain keep-alive is unconditional. autoDispose + keepAlive() lets you keep state based on runtime conditions (success, time, a flag) and release it later. For anything smarter than "always cache" or "never cache," reach for keepAlive().


A few real policies you can now express

With keepAlive() + link.close() + onDispose, you can build:

  • Cache on success, refetch on failure (Pattern 1) — detail screens.
  • Time-boxed cache (Pattern 2) — dashboards, feeds that should refresh periodically.
  • Keep while logged inkeepAlive() after fetch, then ref.listen(authProvider, ...) to link.close() on logout.
  • Manual pin/unpin — store the link and close it from a method when the user clears a cache.

The pattern is always the same: keepAlive() to pin, hold the KeepAliveLink, link.close() to unpin when your condition says so.


Practice Challenges

Challenge 1 — Keep on success. Make an autoDispose FutureProvider<Profile> that caches only if the fetch succeeds.

Show solution
final profileProvider = FutureProvider.autoDispose<Profile>((ref) async {
  final p = await ref.watch(profileRepo).fetch();
  ref.keepAlive(); // only reached on success
  return p;
});

On failure, keepAlive() isn't reached, so the failed state is disposed (retry next time).

Challenge 2 — What's a KeepAliveLink for? Explain link.close().

Show solution

ref.keepAlive() returns a KeepAliveLink that pins the provider in memory. Calling link.close() re-enables auto-disposal — "unpinning" it so it can be destroyed when unwatched again. It's how you make caching temporary/conditional.

Challenge 3 — Cache for 2 minutes. Use the cacheFor pattern on a feed provider.

Show solution
final feedProvider = FutureProvider.autoDispose((ref) async {
  ref.cacheFor(const Duration(minutes: 2));
  return ref.watch(feedRepo).fetch();
});

(With the cacheFor extension that pins, sets a Timer to link.close, and cancels it onDispose.)

Challenge 4 — Right tool. Auth state should live the whole session. Plain keep-alive or autoDispose + keepAlive?

Show solution

Plain keep-alive (no autoDispose) — it's unconditionally app-wide state for the whole session, so there's no condition to manage. keepAlive() is for conditional caching of otherwise-auto-dispose providers.

Challenge 5 — Why does keepAlive belong inside autoDispose? Explain.

Show solution

keepAlive() prevents auto-disposal — but a provider without autoDispose already never auto-disposes, so there's nothing to prevent. It only has meaning inside an autoDispose provider, where it selectively overrides the default tear-down (and riverpod_lint flags misuse).


Questions to test yourself

Q1 (basic). What does ref.keepAlive() do, and what does it return?

Show answer

Inside an autoDispose provider, it immediately prevents the provider from being auto-disposed, and returns a KeepAliveLink you can later use to re-enable disposal.

Q2 (basic). What does link.close() do?

Show answer

Re-enables automatic disposal — "unpins" the provider so it can be destroyed again when no longer watched. It reverses a keepAlive().

Q3 (intermediate). How do you cache only successful fetches?

Show answer

Put ref.keepAlive() after the await in an autoDispose provider. If the fetch throws, that line is never reached, so the failed state stays auto-dispose (refetches next time); on success it's pinned and cached.

Q4 (intermediate). Describe the cache-for-duration pattern.

Show answer

Call keepAlive() to pin the state, start a Timer(duration, link.close) to unpin after the window, and register ref.onDispose(timer.cancel) to clean up the timer if the provider is destroyed first. Packaged as a Ref.cacheFor(duration) extension, it caches a provider for a set time, then auto-disposes.

Q5 (intermediate). When is plain keep-alive (no autoDispose) the right choice instead of keepAlive()?

Show answer

When the state is unconditionally app-wide for the whole session (auth, theme, DI services) — there's no runtime condition to manage. autoDispose + keepAlive() is for conditional caching (keep-on-success, cache-for-duration, keep-while-condition).

Q6 (advanced). Why does keepAlive() only make sense inside an autoDispose provider?

Show answer

keepAlive() exists to override auto-disposal. A provider that isn't autoDispose is keep-alive by default — it never auto-disposes — so there's nothing for keepAlive() to prevent. Using it on a non-autoDispose provider is meaningless (and riverpod_lint warns), because the whole point is selectively keeping an otherwise-disposable provider alive.


Wrapping up

keepAlive turns disposal into a dial:

  • Inside an autoDispose provider, ref.keepAlive() pins the state and returns a KeepAliveLink; link.close() unpins it.
  • Keep-after-success: put ref.keepAlive() after the await — caches successes, discards failures.
  • Cache-for-duration: keepAlive() + Timer(duration, link.close) + onDispose(timer.cancel) (the cacheFor extension).
  • Use plain keep-alive for unconditional app-wide state; use autoDispose + keepAlive() for conditional caching policies.

You've now got fine control over when providers live and die. The flip side is forcing a provider to recompute on demand — for a retry button or pull-to-refresh. Part 5 covers ref.invalidate vs ref.refresh — forcing provider recomputation.