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
Section titled “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
Section titled “NoCache”CachePolicy::NoCacheEvery 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.
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
Section titled “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
Section titled “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
Section titled “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
Section titled “Request deduplication”When multiple parts of your app request the same key at the same time, the RequestPolicy decides what happens.
LatestWins (default)
Section titled “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::LatestWinsIgnoreWhileLoading
Section titled “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::IgnoreWhileLoadingWhat happened: QueryBeginResult
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “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 minutesOr change the client-wide default during construction:
cx.set_global( QueryClient::new(cache_policy, request_policy) .with_gc_time(10 * 60 * 1_000),);Server wins with Fetched<T>
Section titled “Server wins with Fetched<T>”A fetcher normally returns Result<T, E>, and the resource uses whatever CachePolicy the caller supplied through QueryOptions. That policy is decided entirely on the client.
Sometimes the server knows better. A Cache-Control: max-age=30 response header is more authoritative than a TTL you guessed at compile time. The *_with_policy hooks let a fetcher return Result<Fetched<T>, E> instead, carrying a server-derived CachePolicy that overrides the caller’s per-query policy once the fetch resolves successfully.
use_query_with_policy(options, fetcher, cx)- same shape asuse_query(options-first, signal-accepting fetcher, returns(Entity<QueryResource<T, E>>, Subscription)), but the fetcher returnsResult<Fetched<T>, E>.fetch_query_with_policy(&entity, fetcher, cx)- same shape asfetch_query, but the fetcher returnsResult<Fetched<T>, E>.
Fetched<T> has three constructors:
// No override: the resource keeps the caller's policy.Fetched::new(data)
// Override: the resource's policy is replaced with the server's.Fetched::with_policy(data, policy: CachePolicy)
// (`persist` feature) Attach opaque metadata that flows into PersistedEntry::meta.Fetched::with_meta(self, meta)A fetcher that just parsed the response headers can push that TTL back into the resource:
use gpui_query::{ use_query_with_policy, CachePolicy, Fetched, QueryKey, QueryOptions,};use gpui_query_http::cache_policy_from_headers;
let (entity, _sub) = use_query_with_policy( QueryOptions::new(QueryKey::from(["notifications"])), |_signal| async move { let resp = client.get(url).send().await?; let policy = cache_policy_from_headers(resp.headers()) .unwrap_or(CachePolicy::NoCache); Ok(Fetched::with_policy(resp.json().await?, policy)) }, cx,);The server policy replaces the resource’s stored policy immediately after complete_success, so subsequent freshness and SWR checks use the server’s TTL. Returning Fetched::new(data) (or using the plain Result<T, E> hooks) leaves the caller’s policy untouched. Server-wins is strictly opt-in.
Fetched::with_meta requires the persist feature. The opaque meta flows into PersistedEntry::meta when the resource is persisted, so it survives a cold start. See Persistence. For the full HTTP Cache-Control and ETag flow, see HTTP caching.
Putting it together
Section titled “Putting it together”A typical setup looks like this:
// App-wide defaults: 30s TTL with 60s stale window, latest-wins dedup, 10-minute GCcx.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.