Caching
Every query in gpui-query has a cache policy. The policy determines whether a fetch happens at all, and whether stale data gets served while a refetch runs in the background. Picking the right policy is the main lever you have for tuning how "live" your data feels versus how many network requests your app makes.
Cache policies
There are three variants of CachePolicy. The default is Ttl { ttl_ms: 60_000 }, a 60-second TTL. You can override it per query or set a different default when constructing the QueryClient.
NoCache
CachePolicy::NoCache
Every access triggers a fresh fetch. No cached data is stored or returned. Use this for data that changes constantly and where showing a stale value is worse than showing nothing.
The practical effect: begin_request always returns Started, never CacheHit.
Ttl
CachePolicy::Ttl { ttl_ms: 30_000 }
Data is fresh for ttl_ms milliseconds after it was last fetched. While fresh, accessing the query returns CacheHit and skips the network entirely. After the TTL expires, the next access starts a new fetch.
This is the default policy. The built-in default TTL is 60 seconds (60,000 ms). For most read-heavy UIs this is a reasonable starting point.
StaleWhileRevalidate
CachePolicy::StaleWhileRevalidate { ttl_ms: 30_000, stale_ms: 60_000 }
This policy splits the cache lifetime into two windows.
Fresh window (0 to ttl_ms): data is served from cache. No fetch happens. Same as Ttl.
Stale window (ttl_ms to ttl_ms + stale_ms): cached data is returned immediately, and a background refetch starts. The caller gets StaleCacheHit with a request_id for the background fetch.
Expired (past ttl_ms + stale_ms): the data is too old to serve. A normal fetch happens, same as a cache miss.
Use this when your UI benefits from showing slightly outdated data rather than flashing a loading spinner. Feed timelines, dashboards, and list views are common candidates.
How freshness is decided
CachePolicy exposes several methods that control the caching decision internally. You normally do not call these yourself (the library does), but understanding them helps reason about behavior.
is_fresh(age_ms)- returnstruewhenage_msis within the TTL windowis_stale_but_serveable(age_ms)- returnstruewhen the age is past TTL but within the stale window (onlyStaleWhileRevalidate)is_expired(age_ms)- returnstruewhen the age exceeds the total valid window.NoCachealways returnstruecan_short_circuit(age_ms)- whether the policy can skip a fetch entirely (true forTtlandStaleWhileRevalidatewhen data is fresh)can_serve_stale(age_ms)- whether stale data can be returned (true only forStaleWhileRevalidate)total_valid_ms()- returnsttl_ms + stale_msfor SWR,ttl_msforTtl,NoneforNoCache
Setting the policy
Pass the policy when creating a QueryClient to set the app-wide default.
cx.set_global(QueryClient::new(
CachePolicy::StaleWhileRevalidate { ttl_ms: 30_000, stale_ms: 60_000 },
RequestPolicy::LatestWins,
));
Override per query through QueryOptions:
let opts = QueryOptions::new(QueryKey::from(["notifications"]))
.cache_policy(CachePolicy::NoCache);
Or use resource_with_policies on the client directly:
let entity = client.resource_with_policies::<Vec<Notification>, MyError>(
QueryKey::from(["notifications"]),
CachePolicy::NoCache,
RequestPolicy::LatestWins,
cx,
);
Request deduplication
When multiple parts of your app request the same key at the same time, the RequestPolicy decides what happens.
LatestWins (default)
A new request cancels the in-flight one and starts fresh. This is the right choice when you always want the most recent data and do not care about wasting the earlier request.
RequestPolicy::LatestWins
IgnoreWhileLoading
If a request is already in progress, new requests for the same key are ignored. The original request runs to completion. Use this when fetches are idempotent and you want to avoid redundant work.
RequestPolicy::IgnoreWhileLoading
What happened: QueryBeginResult
When you call begin_request on a resource, the return value tells you exactly what happened.
pub enum QueryBeginResult {
Started { request_id, status, replaced_request_id },
CacheHit,
StaleCacheHit { request_id, status, replaced_request_id },
IgnoredWhileLoading { active_request_id },
}
Started- a new fetch was kicked off.replaced_request_idisSomeif this request cancelled a previous one (underLatestWins).CacheHit- the cache is fresh, no fetch needed. Use the cached data.StaleCacheHit- data is stale but still serveable. Return the stale data to the UI immediately, then use therequest_idto run a background fetch.IgnoredWhileLoading- a request was already running and the policy isIgnoreWhileLoading.
Forcing a refetch
QueryFetchMode controls whether the cache is consulted.
Normal(default) - respects the cache policy. ReturnsCacheHitif fresh.Force- bypasses cache freshness. Always starts a new fetch. Still respectsIgnoreWhileLoadingif a request is already in flight.
Use Force when the user explicitly requests fresh data (pull-to-refresh, a manual reload button).
client.force_fetch_query::<UserData, MyError>(
QueryKey::from(["users", "42"]),
cache_policy,
request_policy,
now_ms,
cx,
);
Bulk operations on QueryClient
The QueryClient has bulk methods that operate across all resources matching a filter. These use QueryKeyFilter to select which queries to touch.
Invalidation
invalidate_queries clears cache freshness on matching resources. The next access to those queries will trigger a refetch. It does not cancel in-flight requests.
let client = cx.global::<QueryClient>();
client.invalidate_queries(
&QueryKeyFilter::Prefix(&QueryKey::from(["users"])),
cx,
);
This is the standard pattern after a mutation: invalidate the affected query prefix so the next read fetches fresh data.
Removal
remove_queries drops matching resources from the registry entirely. Unlike invalidation, which keeps the entity and just expires its cache, removal deletes the entity.
client.remove_queries(&QueryKeyFilter::All, cx);
QueryKeyFilter variants
Exact(&key)- matches only the exact keyPrefix(&key)- matches all keys that start with the given prefix segmentsAll- matches every key
Prefix matching is hierarchical. Prefix(&QueryKey::from(["users"])) matches ["users"], ["users", "42"], and ["users", "42", "posts"].
Optimistic updates
Sometimes you want to update the UI before the server confirms the change. set_query_data writes a value directly into the cache, storing the previous value for rollback.
let client = cx.global::<QueryClient>();
client.set_query_data::<Vec<User>, MyError>(
&key,
updated_user_list,
cx,
);
If the mutation later fails, roll back to the previous data:
client.rollback_query_data::<Vec<User>, MyError>(&key, cx);
This restores the data that was in the resource before set_query_data was called. If there was no previous data, the rollback does nothing and returns false.
Garbage collection
Queries that no component is observing will linger in memory until garbage collection picks them up. The gc method on QueryClient removes resources that have been idle longer than gc_time_ms.
let client = cx.global::<QueryClient>();
client.gc(cx, now_ms);
The default GC time is 5 minutes (300,000 ms). You can change this per query through QueryOptions:
let opts = QueryOptions::new(key).gc_time_ms(10 * 60 * 1_000); // 10 minutes
Or change the client-wide default during construction:
cx.set_global(
QueryClient::new(cache_policy, request_policy)
.with_gc_time(10 * 60 * 1_000),
);
Putting it together
A typical setup looks like this:
// App-wide defaults: 30s TTL with 60s stale window, latest-wins dedup, 10-minute GC
cx.set_global(
QueryClient::new(
CachePolicy::StaleWhileRevalidate { ttl_ms: 30_000, stale_ms: 60_000 },
RequestPolicy::LatestWins,
)
.with_gc_time(10 * 60 * 1_000),
);
Then override per query as needed. A real-time notifications feed might use NoCache. A rarely-changing config endpoint might use Ttl { ttl_ms: 300_000 }. Most things fall somewhere in between.
See Queries for how to define queries that use these policies, QueryClient for the full client API, and Error Handling for what happens when a fetch fails.