Summary
useQuery caches only completed results. The cache entry is written after the response resolves, so two calls for the same key issued in the same tick both miss the cache and both fire a request. useFetch and useMutation have no cache at all. There is no dedupe by key, no shared in-flight promise, and no hook where an app could add one.
For a read-only endpoint that is wasted bandwidth. For anything that consumes a single-use token it is a correctness bug, and the application is forced to own it.
Impact (real app)
analyticshq issues rotating refresh tokens: /auth/refresh spends the token it is given and returns a new pair. The dashboard fires several authenticated requests concurrently (/api/me at resources/views/dashboard.stx:1031, the 15s realtime poll at :1049, plus whatever the page is doing), so one expired access token produces several simultaneous 401s.
We had to hand-write single-flight in the store. resources/stores/session.ts:140-152:
SINGLE-FLIGHT, and that is not an optimisation.
The refresh token is single-use with rotation: /auth/refresh spends the one it is given and returns a new pair. The dashboard fires several authenticated requests at once (/api/me, the realtime poll, whatever the page is doing), so an expired access token produces SEVERAL simultaneous 401s. Without this, each would exchange the same token; the first wins and the rest are refused, which would sign the visitor out in the middle of a session that had just successfully renewed.
The implementation is ~50 lines (session.ts:153-204): a module-scoped sessionRefreshInFlight promise slot, share-the-promise on concurrent entry, and release-after-settle guarded by an identity check —
sessionRefreshInFlight = attempt
attempt
.catch(() => 'unavailable' as SessionRefreshOutcome)
.then(() => { if (sessionRefreshInFlight === attempt) sessionRefreshInFlight = null })— plus a three-way outcome type (types/session.d.ts:58-64) needed to distinguish "offline" from "session really over", because a wifi blip must not sign anyone out (session.ts:176-180).
None of this is application logic. It is a data-layer concern that every stx app with concurrent authenticated requests will have to reinvent.
Evidence in stx
packages/stx/src/signals.ts:1189 — the cache:
var _queryCache = {};Read before the request, signals.ts:1224-1238:
var cached = _queryCache[key];
if (cached && (Date.now() - cached.timestamp < staleTime)) { … return; }
if (cached) { data.set(cached.data); isStale.set(true); }Written only after it resolves, signals.ts:1250:
_queryCache[key] = { data: transformed, timestamp: Date.now() };There is no slot holding the pending promise, so two fetchData() invocations for the same key before the first resolves both reach __stxFetch (:1244). grep -rn 'inFlight\|dedup\|dedupingInterval' packages/stx/src returns nothing in the data layer.
useFetch (signals.ts:932-1026) and useMutation (signals.ts:1300-1355) do not consult _queryCache at all.
Prior art
- TanStack Query: concurrent
useQuerycalls with the same key share one fetch automatically;queryClient.fetchQueryreturns the in-flight promise. - SWR:
dedupingInterval(2s default) collapses duplicate requests for the same key. - axios-auth-refresh: one shared refresh promise; queued requests replay after it settles.
- Angular: interceptor +
shareReplay(1)on the refresh observable.
Ask
- Hold the pending promise in
_queryCache(e.g.{ promise, data, timestamp }) and return it when a second call arrives for the same key before the first settles. - Add a
dedupe/dedupingIntervaloption so read-heavy pages can collapse near-simultaneous calls, and document the default. - Ship a first-class token-refresh hook on the shared fetch instance —
onResponseErrorthat may return a replacement response, with the framework guaranteeing the refresh itself is single-flight. Every app that has rotating tokens needs exactly this and cannot express it today. - Extend the dedupe to
useFetch, which has no cache at all.