@zakkster/lite-query
v2.2.0
Published
Reactive async cache with cross-tab coherence, streaming queries, and async coordination. Built on lite-signal.
Readme
@zakkster/lite-query
Reactive async cache with cross-tab coherence. Built on @zakkster/lite-signal. ~6KB minified+gzipped, framework-agnostic, 437 tests, zero runtime dependencies outside the lite ecosystem.
~3.3x faster on invalidate, ~3.1x faster on infinite pagination, ~2.5x faster on mutations, ~1.6-2.0x on every read path, at less transient memory on the write-heavy scenarios vs @tanstack/query-core 5.102.8 (see Performance). Cross-tab cache coherence and cross-tab fetch dedup built in.
const todos = query(qc, {
key: () => ['todos', filter()],
fetcher: async ({ key, signal }) => fetch(`/api/${key.join('/')}`, { signal }).then(r => r.json()),
});
effect(() => {
if (todos.loading()) render('Loading...');
else if (todos.error()) render(`Error: ${todos.error().message}`);
else render(todos.data());
});That's the entire surface. Two functions and a client. The library is small because the idea is small.
Why does this exist?
Three reasons.
Cross-tab cache coherence as a first-class feature. Open your app in two tabs. Update a todo in one. The other tab's UI updates instantly -- same cache state, no extra fetch, no polling, no service worker. Just BroadcastChannel, opt-in (crossTab: true), and the library handles the propagation. TanStack Query and SWR don't ship this -- you can wire it manually with their plugins, but it's not in the box.
Cross-tab fetch deduplication -- five tabs, one request. This is the feature no other query library ships. With sharedFetch: true and a leader oracle wired from @zakkster/lite-channel, only the leader tab issues network requests; every other tab receives the result over BroadcastChannel. Five tabs polling the same dashboard stop hammering your API five times per cycle -- the leader fetches once and shares. Followers fall back to self-fetching if no leader can serve them, so correctness never depends on the election state. (See Cross-tab fetch deduplication below.)
Framework-agnostic. No useQuery hook. No React, no Solid, no Vue dependency. The return value of query() is a plain object with signal accessors (data(), error(), loading(), status(), fetching()). Read them inside an effect from @zakkster/lite-signal and you get reactivity. Wrap them in 20 lines if you want a React/Vue integration. The core stays lean.
Honest size. The whole library is 710 lines of JS, ~5-6 KB minified+gzipped (plus its one required peer, @zakkster/lite-signal). For comparison: TanStack Query's core is ~13 KB, SWR is ~5 KB. Lite-query lands in the same weight class as SWR while shipping a richer feature set (retry, mutations, cross-tab) and shedding the React lock-in.
Facts table
The honest comparison. Numbers are min+gzip, current as of writing.
| | lite-query | TanStack Query | SWR |
|---|---|---|---|
| Bundle (core, min+gzip) | ~6 KB | ~13 KB | ~5 KB |
| Framework requirement | None | React / Vue / Solid / Svelte adapter | React |
| Cross-tab cache sync | Built-in (opt-in) | Manual (plugin) | Manual |
| Cross-tab fetch dedup (leader election) | Built-in (opt-in) | No | No |
| Stale-while-revalidate | Yes | Yes | Yes |
| Interval polling | refetchInterval (one client-wide scanner) | refetchInterval | refreshInterval |
| Keep previous data on key swap | keepPreviousData + isPlaceholder() | placeholderData / keepPreviousData | keepPreviousData |
| Retry with backoff | Yes | Yes | No (manual) |
| Optimistic updates + rollback | Yes (onMutate ctx) | Yes (onMutate ctx) | Yes (mutate API) |
| Mutation race protection (mutationGen) | Yes | Yes | Partial |
| Reactive keys | Native (signals) | Via framework state | Via framework state |
| Multi-shot / streaming queries | streamQuery (SSE/ws/cursor) | Experimental (streamedQuery) | No |
| Cursor pagination (infinite) | infiniteQuery (built-in) | useInfiniteQuery | useSWRInfinite |
| Route-loader prefetch | qc.prefetch (built-in) | queryClient.prefetchQuery | preload |
| Cache persistence + boot hydration | persistQueryClient + qc.dehydrate/hydrate (built-in) | Plugin (@tanstack/query-persist-client) | Manual |
| Abort reason vocabulary | Yes (signal.reason) | No | No |
| Per-query timeout | Yes | No (manual via fetcher) | No (manual via fetcher) |
| Devtools feed | qc.inspect (31-type push feed) | Feed + bundled UI | Feed + UI |
| Devtools UI | External (lite-studio) | Yes (mature) | Yes |
| Tests | 437 | ~hundreds | ~hundreds |
| Foundation | Signals (lite-signal) | Observer pattern | SWR algo + hooks |
Where lite-query trails: the devtools panel is external (lite-studio), not bundled. What lite-query ships is the FEED a panel renders (qc.inspect, zero-cost when off). Where it leads: cross-tab, cursor pagination, and the signal-native composition story.
Performance
Measured against @tanstack/query-core 5.102.8 on Node 26 (SWR is React-coupled -- no framework-agnostic core to compare against, excluded for honest apples-to-apples). Same fetcher, same keys, same observer pattern; every comparative scenario asserts both libraries did the same unit of work before its timing is trusted. Both libraries run their full lookup, observer, and cleanup paths -- nothing stubbed.
| Scenario | lite-query | TanStack query-core | Speedup | |---|---:|---:|---:| | Cold attach -> resolve -> dispose | ~300,000 ops/sec | ~175,000 ops/sec | ~1.6x | | Warm cache hit (already resolved) | ~1,350,000 ops/sec | ~860,000 ops/sec | ~1.6x | | Invalidate 50 observed queries | ~31,000 ops/sec | ~9,500 ops/sec | ~3.3x | | Mutation w/ optimistic + rollback | ~320,000 ops/sec | ~128,000 ops/sec | ~2.5x | | 1000 parallel queries / cycle | ~510 ops/sec | ~275 ops/sec | ~1.9x | | Prefetch unique key -> resolve | ~680,000 ops/sec | ~335,000 ops/sec | ~2.0x | | Dehydrate -> hydrate cycle | ~62,000 ops/sec | ~62,000 ops/sec | ~1.0x | | Cache write + listener installed | ~1,950,000 ops/sec | ~1,200,000 ops/sec | ~1.6x | | Infinite: 4-page paginate cycle | ~139,000 ops/sec | ~44,000 ops/sec | ~3.1x | | Offline drain (philosophy-differing) | ~627,000 ops/sec | ~100,000 ops/sec | ~6x |
Where lite-query wins decisively: invalidation (~3.3x -- the signal-graph propagates without TanStack's observer-notification fan-out and per-query Promise allocation), mutations with optimistic updates (~2.5x -- the setQueryData -> onMutate -> fn -> rollback path is a direct signal write chain, not a queued observer cycle), and infinite pagination (~3.1x, at roughly half the transient memory per cycle). Every read-path scenario (cold, warm, prefetch) lands ~1.6-2.0x ahead. Dehydrate/hydrate is a dead heat -- both walk the same entry map.
The offline-drain row is not a like-for-like and is labeled as such in the bench output: lite-query DURABLY enqueues an offline mutation and replays it on demand (at-least-once across a reload), while query-core PAUSES the mutation in memory and resumes it when connectivity returns (best-effort, lost on reload). Read it as each library's offline path, never as an equivalence.
Memory: lite-query allocates less transient memory per operation where it matters most -- invalidate (~0.54x), mutation (~0.84x), infinite paginate (~0.48x). On warm reads, prefetch, and the cache-write-with-listener path it allocates more transient (the copying pooled-record read and per-op result objects), all collected in young-gen with zero retained growth -- the zero-GC guarantee is on the warm READ loop (proven by the byte-frozen torture GATE), not on cold write/serialize paths.
Persistence adds zero warm-path allocation: no new per-entry slot, no new signal, no read-path branch (dehydrate walks the map and is cold by definition; the write hook is a single persistHook !== null test at six commit/settle sites, none on the 200000-iteration warm-read loop). With no adapter installed the torture GATE line is byte-identical to the pre-persistence build (GATE leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | ok), including a 4096-cycle dehydrate/hydrate/teardown loop.
Reproduce: npm install && npm run bench (Node 18+, includes warmup, transient + retained byte tracking). Numbers vary ~10-15% run-to-run; the ratios are stable.
Install
npm install @zakkster/lite-query @zakkster/lite-signalOne required peer dependency: @zakkster/lite-signal (^1.5.0 -- the query and stream watchers use createRoot).
Two subpath entry points are optional and pull one extra peer each, only if you use them:
npm install @zakkster/lite-stream # for @zakkster/lite-query/stream
npm install @zakkster/lite-await # for @zakkster/lite-query/awaitThe core (@zakkster/lite-query) imports neither, so core-only installs see no extra requirement and no peer warnings. The rest of the @zakkster/* family composes with lite-query -- see Ecosystem below -- but nothing else is required.
Quick taste
import { signal, effect } from '@zakkster/lite-signal';
import { queryClient, query, mutation } from '@zakkster/lite-query';
const qc = queryClient({
defaultStaleTime: 5_000,
defaultCacheTime: 5 * 60_000,
crossTab: true, // <- cross-tab sync
});
// A reactive query
const userId = signal(1);
const user = query(qc, {
key: () => ['user', userId()],
fetcher: async ({ key, signal }) =>
fetch(`/api/users/${key[1]}`, { signal }).then(r => r.json()),
});
// Drive UI from signals
effect(() => {
if (user.loading()) renderSpinner();
else if (user.error()) renderError(user.error());
else renderUser(user.data());
});
// Mutate with optimistic rollback
const updateUser = mutation(qc, {
fn: (patch) => fetch(`/api/users/${patch.id}`, {
method: 'PUT', body: JSON.stringify(patch),
}).then(r => r.json()),
onMutate: (patch) => {
const prev = qc.getQueryData(['user', patch.id]);
qc.setQueryData(['user', patch.id], { ...prev, ...patch });
return { prev }; // context -> onError
},
onError: (err, patch, ctx) => qc.setQueryData(['user', patch.id], ctx.prev),
onSuccess: () => qc.invalidate(['user']),
});
await updateUser.mutate({ id: 1, name: 'Zahary' });For more, see QuickStart.md and Cookbook.md.
Core concepts
queryClient(options) -- the cache. Owns a Map of cache entries keyed by hashed query keys. Owns the BroadcastChannel if crossTab: true. Exposes cache operations: getQueryData, setQueryData, invalidate, removeQueries, clear, dispose.
query(qc, { key, fetcher, ... }) -- a reactive query. The key can be a value (['todos']) or a function (() => ['todo', id()]). A function key subscribes to the signals it reads -- changing them triggers a refetch with the new key. The query has no observers until something reads one of its accessors (data() / error() / loading() / status() / fetching()) inside an effect. No observers -> no fetch. This is the lazy property.
mutation(qc, { fn, onMutate, ... }) -- an async action with optimistic support. Calling mutation.mutate(vars) returns a Promise. The optional callbacks run in a strict phase order: onMutate(vars) returns a context object -> fn(vars) runs -> onSuccess(data, vars, ctx) or onError(err, vars, ctx) -> onSettled(data, err, vars, ctx). onSettled always fires, even if earlier callbacks threw. Concurrent mutations are gen-guarded: a slow first cannot overwrite a fast second's state.
Cross-tab coherence. When crossTab: true, the client wires a BroadcastChannel. Explicit cache mutations (setQueryData, invalidate, removeQueries, clear) propagate to other tabs. Background fetch results do not propagate (otherwise tabs would broadcast-storm). Receiving tabs apply changes locally without re-broadcasting (echo suppression via a processingRemote flag).
API surface
The complete public surface. Everything not listed here is internal.
queryClient(options?)
Returns { options, getQueryData, setQueryData, invalidate, removeQueries, clear, dispose }.
queryClient({
defaultStaleTime?: number, // ms; 0 (default) = stale on every attach
defaultCacheTime?: number, // ms; default 5 * 60_000 (5 min)
defaultTimeout?: number, // ms; default Infinity
retry?: number | ((attempt, err) => boolean), // default 3
retryDelay?: (attempt) => number, // default: min(2^(n-1) * 1000, 30s)
crossTab?: boolean, // default false
crossTabChannel?: string, // default 'lite-query'
sharedFetch?: boolean, // default false; dedup fetches across tabs (needs isLeader)
isLeader?: () => boolean, // leader oracle, e.g. lite-channel's sync.isLeader
sharedFetchTimeout?: number, // ms; default 3000; follower fallback self-fetch delay
sharedStream?: boolean, // default false; share ONE stream connection across tabs (needs isLeader)
streamIdleTimeout?: number, // ms; default = sharedFetchTimeout; follower self-connect delay
now?: () => number, // injectable for tests
setTimeout?: typeof setTimeout,
clearTimeout?: typeof clearTimeout,
broadcastChannel?: typeof BroadcastChannel, // injectable for tests
})query(qc, opts)
Returns { data, error, loading, fetching, status, refetch, dispose } -- all functions.
query(qc, {
key: any[] | (() => any[]), // value or reactive function
fetcher: async ({ key, signal }) => any,
staleTime?: number,
cacheTime?: number,
timeout?: number,
retry?: number | function,
retryDelay?: function,
enabled?: boolean | (() => boolean), // gate on a reactive condition
equals?: (a, b) => boolean, // default Object.is
})mutation(qc, opts)
Returns { data, error, loading, status, mutate, reset }.
mutation(qc, {
fn: async (vars) => any,
onMutate?: async (vars) => any, // returns context
onSuccess?: async (data, vars, ctx) => void,
onError?: async (err, vars, ctx) => void,
onSettled?: async (data, err, vars, ctx) => void, // ALWAYS fires
})2.0 BREAKING (ON-3): a mutation that rejects with a falsy value (
null,0,"",undefined) now settlesstatus() === "error"with the value held verbatim inerror()-- previously a falsy rejection was mis-read as success. Branch onstatus(), never onerror()truthiness. A mutation that resolves with a falsy value is unaffected.
Offline mutation queue -- queue: true
Opt in per mutation to a durable queue that holds a mutation while the transport is down and replays it on reconnect. You own the connectivity oracle (offline); the library never reads navigator.onLine, never polls, never watches a socket. Replay is caller-triggered and at-least-once across a crash -- every record carries a stable id, so use it as your server-side idempotency key.
const save = mutation(qc, {
fn: async (todo) => api.saveTodo(todo),
queue: true, // opt in (validated at construction)
offline: () => !navigator.onLine, // YOUR oracle -- the isLeader precedent
name: "saveTodo", // what a reloaded tab resolves the handler by
queueKey: ["todos"], // the query key this mutation targets
});
// While offline, mutate() enqueues instead of dispatching:
const receipt = await save.mutate({ id: 1, title: "buy milk" });
// -> { queued: true, id: "c1a2b3:1" }; save.status() === "queued"
// On reconnect, the CALLER replays (order preserved, single-flight, per-item results):
const result = await qc.replayQueue((record) =>
record.name === "saveTodo" ? ((vars) => api.saveTodo(vars)) : null
);
// result: { status, total, replayed, failed, dropped, items:[{ id, key, status, value, reason }] }- Fail closed. A present-but-non-boolean
queue, orqueue: truewithout a functionoffline/ stringname/ arrayqueueKey, throwsTypeErrorat construction (nothing built). Anoffline()that throws or returns a non-boolean rejectsmutate()witherr.code === "LQ_OFFLINE_ORACLE"-- an unverified connectivity state never silently picks a branch. A full queue (maxQueue, default 100) rejectsLQ_QUEUE_FULL, never a silent drop. - Durable. Wire
queueSave/queueLoadsibling thunks onpersistQueryClient(both or neither); the queue rides the same version stamp and throttle window as the cache. On boothandle.queueRestoredresolves the restore outcome; a mismatched or corrupt queue drops whole and says so (on the promise and aqueue:dropfeed event). - Replay dispositions. A record whose cache entry no longer exists, or whose handler cannot be resolved, is dropped with a surfaced reason (never silently retried); a handler rejection keeps the item queued (
tries++); a resolution removes it.qc.dropQueued(id)is the explicit exit for a permanently-rejected item.triesis advisory and memory-first -- bumped in memory per dispatch and persisted only with the next queue write, so it is a retry hint, not a crash-durable counter (the recordidis the durable idempotency key). - Boot order. Replay resolves each record against a live cache entry, so
await Promise.all([persist.restored, persist.queueRestored])before your firstreplayQueue-- if the cache has not re-seeded its entries first, every restored record drops asentry-missing(a drop is a removal, so the mutation is lost). qc.queueSize()reports the durable count (0 when the queue was never touched --nullis not an empty array).
streamQuery(qc, opts) -- subpath @zakkster/lite-query/stream
The multi-shot sibling of query(): subscribe a cache key to an async iterable -- SSE frames, websocket messages, a paginated cursor, a pubsub topic -- instead of a one-shot fetch. Values are pumped through @zakkster/lite-stream into the same cache entry a query would use, so getQueryData, invalidate, and removeQueries operate on a stream uniformly. Requires @zakkster/lite-stream.
import { streamQuery } from "@zakkster/lite-query/stream";
const ticks = streamQuery(qc, {
key: ["prices", symbol], // static or reactive, like query()
stream: ({ key, signal }) => sseIterable(`/prices/${key[1]}`, signal),
mode: "latest", // "latest" (default) | "buffer"
});
effect(() => {
if (ticks.loading()) return; // status: pending (no value yet)
render(ticks.data()); // updates on every frame
});Returns { data, error, status, loading, done, count, droppedCount, restart, dispose }. Status runs idle -> pending -> streaming -> success | error; loading() is pending, done() is success. In "latest" mode data() is the most recent value (one signal write per frame, zero allocation); in "buffer" mode (maxBuffer required) data() is a sliding window array and droppedCount() counts values that fell off the back. count()/droppedCount() are non-reactive snapshots -- read them next to data() in the same effect to see them advance.
Same lifecycle guarantees as query(): lazy (no connection until observed), abort-on-detach (last observer leaving calls iterator.return()), reactive-key restart, and an enabled gate. invalidate(key) aborts and re-establishes the stream. Streaming data does not cross tabs in 1.1.0 (each tab owns its connection); only invalidate propagates, so every tab reconnects. See demo/stream-query-demo.html in the repo for a live latest/buffer/lifecycle walkthrough.
Async coordination -- subpath @zakkster/lite-query/await
Bridges a reactive query to a one-shot promise -- for imperative flows, route loaders, or tests. Re-exports all 18 @zakkster/lite-await 1.3.0 primitives verbatim -- single source of truth, zero wrapping -- and adds two query-native helpers. Requires @zakkster/lite-await.
import { whenQuery, whenAllQueries } from "@zakkster/lite-query/await";
const user = await whenQuery(userQ, { timeout: 5000 }); // resolves with data(); rejects with error()
const [a, b] = await whenAllQueries([aQ, bQ]); // fail-fast, data in input order
// any predicate over status -- e.g. wait for a streamQuery's first frame:
await whenQuery(ticks, (status) => status === "streaming");The full re-exported surface: whenSignal, whenTruthy, whenEquals, allOf, anyOf, raceOf, withTimeout, withAbort, fromPromise, TimeoutError, plus the eight added for 1.3.0 parity -- allSettledOf, withResolvers, tryFn, delay, withRetry, mapLimit, whenStatechart, createAwaitScope. VERSION is deliberately not re-exported: lite-query owns its own VERSION const (see the . entry) so this subpath never misreports the package version.
Boundaries worth keeping straight -- the re-exports and query() are complementary, not overlapping:
withRetry(fn, opts)is not the cache's refetch. It retries an arbitrary async factory with exponential backoff for one-shot imperative work (a route loader, a mutation side effect). Cached, observed, deduplicated reads arequery()'s job. Wrapping aquery()fetcher inwithRetrydouble-owns the retry policy -- don't.delay(ms, opts)advances wall clock, not a mock clock. It is a real, abort-awaresetTimeoutpromise; in tests prefer driving state directly (setQueryData/ a manual iterator) over sleeping real time.createAwaitScope(ctrl?)binds N signal-aware awaiters to oneAbortController(owns a fresh one when omitted; borrows when passed, andscope.abort()then throws). Upstreamdecisions/0007owns the documented contract; we only re-export it.fromPromisevocabulary is final -- lite-awaitdecisions/0009verdict = REJECT (accepted 2026-09-01). The name and its signal-state projection are locked; the mapping table is permanent.
Devtools feed -- qc.inspect(hook)
Observability with a zero-cost off switch. qc.inspect(hook) installs ONE observe-only hook -- a push-mode stream of cache truth a panel renders -- and returns an idempotent uninstall thunk. The panel is not here: it belongs to lite-studio (its repo, its session), which consumes this feed's vocabulary. lite-query owes the feed, not a UI; nothing is imported in either direction.
const stop = qc.inspect((e) => {
// e is POOLED per type -- copy what you keep
if (e.type === "fetch:settle") console.log(e.keyHash, e.ok ? "ok" : "error");
});
// ... later
stop(); // idempotentEvery event is one monomorphic record -- exactly 10 own keys, always present: { type, ts, key, keyHash, from, to, reason, count, ok, value } (a field that does not apply is null; count is 0, ok is false). The 31 domain:verb types cover the whole lifecycle: entry:create|attach|detach|gc|remove|status|stale, fetch:dispatch|settle|abort, tab:send|receive, shared:request|fallback|serve, stream:start|value|done|error, mutation:start|settle, persist:hydrate|save, the shared-stream trio stream:project|promote|gap, and the offline-queue quintet queue:enqueue|restore|replay|settle|drop. Full field table in llms.txt.
Three contracts to keep straight:
- Hook-must-copy. Records are pooled per type and overwritten in place -- the same object is handed back for every event of that type. Copy what you retain (
{ ...e }or the fields you need). This is what makes an installed hook allocate zero per event. - Observe-only, synchronous. Dispatch is synchronous at the emit site (emission order is the truth). A throwing hook is contained fail-closed: the feed auto-uninstalls, logs once via
console.error, and the in-progress cache write completes -- a panel bug never aborts a commit. Never throw, never mutate from a hook. - Zero cost when off. With no hook installed the warm read path allocates exactly what 1.4.0 does and the byte-frozen torture GATE line is unchanged -- every emit site is a single branch-predicted
!== nulltest. The feed is also independent ofpersistQueryClient's private write seam: uninstalling either never disturbs the other.
Honest behaviour notes
A few things to know that aren't obvious from the API:
loading()vsfetching()--loading()is true only on the initial fetch (no data yet).fetching()is true on any fetch, including background revalidation. Useloadingfor spinners,fetchingfor subtle indicators.
A few things to know that aren't obvious from the API:
loading()vsfetching()--loading()is true only on the initial fetch (no data yet).fetching()is true on any fetch, including background revalidation. Useloadingfor spinners,fetchingfor subtle indicators.staleTime: 0(the default) means every fresh observer attach triggers a refetch. If you want "cache hits don't refetch", setstaleTime: Infinityor some large value.- Cross-tab broadcasts only explicit mutations. Background fetch results stay local. If Tab A and Tab B both mount a query for the same key, they each fetch independently and store independently. Only
setQueryData/invalidate/removeQueriespropagate. - Same-entry watcher re-runs don't refetch. If your reactive
keyfunction reads multiple signals but produces the same key on re-run, the watcher detects that and doesn't tear down / re-create the attachment. Refetch only happens on a real key change. mutate(vars)returns a promise reflecting that call's outcome. Two rapidmutate()calls each get their own promise, but the mutation's state signals (data,status,error) reflect only the latest one. Gen-guarded.onSettledalways fires. Even ifonMutate/fn/onSuccess/onErrorthrew. Callback errors are contained so a buggy callback can't lock the user's UI.AbortSignal.reasoncarries one of'lite-query:detach' | 'lite-query:refetch' | 'lite-query:removed' | 'lite-query:timeout'so your fetcher can make smart retry decisions.
Cross-tab fetch deduplication
The feature no other query library ships. Five tabs open to the same dashboard normally means five identical API calls every poll cycle. With leader-election shared fetch, the leader tab fetches once and broadcasts the result; the others get it free.
import { createTabSync } from '@zakkster/lite-channel';
import { queryClient } from '@zakkster/lite-query';
const sync = createTabSync(); // lite-channel: leader election as a signal
const qc = queryClient({
crossTab: true,
sharedFetch: true,
isLeader: () => sync.isLeader(), // wire the leader oracle
sharedFetchTimeout: 3000, // fallback self-fetch if no leader serves in time
});How it works:
- A follower tab needs data. Instead of fetching, it broadcasts a
fetch-reqand shows a loading state. - The leader receives the request and fetches (deduping if it's already fetching that key), then broadcasts the result. Every tab observing that key updates.
- Liveness guarantee: each follower arms a fallback timer. If no leader responds within
sharedFetchTimeout-- election in progress, leader absent, or the leader doesn't have that query defined -- the follower self-fetches. The UI never hangs.
Honest constraints:
- The leader can only fulfill a request for a query it currently has alive (observed, or within
cacheTime). If the leader navigated away and its entry was GC'd, the follower falls back to self-fetch. Correctness is always preserved; the dedup benefit is best-effort during those transitions. sharedFetchrequires bothcrossTab: trueand a validisLeaderfunction. WithoutisLeader, it's inert and every tab fetches independently -- a safe default with no breakage.- This composes directly with
@zakkster/lite-channel, but lite-query has no hard dependency on it. You supplyisLeaderfrom any source; lite-channel just happens to expose it as a ready-made signal.
Cross-tab shared streams
The sequel to shared fetch. Shared fetch collapses N tabs to one request; shared streams collapse N tabs to one connection. Five tabs subscribed to the same SSE/websocket feed normally open five sockets -- with sharedStream: true the leader tab owns the one iterator and broadcasts each frame; every other tab projects the frames into its local cache and holds no iterator at all.
const qc = queryClient({
crossTab: true,
broadcastChannel: BroadcastChannel,
sharedStream: true,
isLeader: () => channel.sync.isLeader(), // same oracle as sharedFetch
streamIdleTimeout: 3000, // self-connect if no frame arrives in time
});
// Identical call in every tab -- the sharing is transparent.
const ticks = streamQuery(qc, {
key: () => ["prices", symbol()],
stream: ({ key, signal }) => sseSource(`/stream/${key[1]}`, signal),
mode: "buffer",
maxBuffer: 100,
});
effect(() => render(ticks.data())); // followers see the same window, liveHow it holds together:
- One connection, transparently. The leader opens the iterator; followers receive frames as
BroadcastChannelmessages.data(),count(), anddroppedCount()read the projected window exactly as a local stream reads its own -- a slow follower bounds itself through its ownmaxBufferand counts its own drops. - At-most-once (OR-4). The epoch/seq gate runs before any signal write, so duplicated and reordered frames are structurally impossible; frame loss on failover is permitted and counted (surfaced as
stream:gapin the feed). - Failover you don't wire. If the leader closes, is killed, or hangs, a follower promotes and starts a fresh iterator (nothing adopts a dead leader's iterator). The
(epochSeq, clientId)tiebreak leaves exactly one owner; losers abdicate and revert to projecting. - Liveness (OR-3): if no frame arrives within
streamIdleTimeout, the follower self-connects. Correctness never depends on election state -- proven under both an all-true and an all-falseisLeaderoracle. - Zero per-follower leader state (G5): no replay buffer, no acks, no cursors. The leader's memory is identical whether 1 or 4 followers are stalled.
sharedStream requires crossTab: true, a channel, and isLeader; inert otherwise (each tab owns its own connection -- exactly the pre-2.0 behaviour). No transport ownership and no lite-channel dependency: frames ride the existing crossTab channel, and lite-channel supplies only the isLeader oracle.
Why an ecosystem, not just a library
lite-query isn't a standalone package -- it's one piece of a reactive platform where everything shares a single substrate (@zakkster/lite-signal) and one design discipline (zero-GC, fine-grained, framework-agnostic). That coherence is the point:
- Its reactive keys read signals from
lite-router, so route changes drive refetches with no glue code. - Its cross-tab layer is the same
BroadcastChannelplaybook aslite-channel, and its fetch dedup needs only anisLeaderoracle, for whichlite-channelis the convenient source. - Its cache can persist through
lite-persistfor instant cold-starts. - Its mutations pair naturally with
lite-formfor validated submissions. - Everything renders through
lite-element,lite-scene, orlite-virtualwithout a framework in sight.
TanStack Query is excellent -- and welded to React's render model. lite-query is for teams who've committed to signals end to end and want their data layer to compose with the rest of their stack rather than fight it. Adopt one @zakkster/* package and the others click in, because they were built to.
Ecosystem
Lite-query is one library in a signals-native platform. Each package is single-purpose, zero-GC, framework-agnostic, and built on the same lite-signal core:
@zakkster/lite-signal-- the reactive core.signal/computed/effect/batch/untrack/onCleanup/isTracking. Everything reactive here is one of these.@zakkster/lite-store-- fine-grained reactivity for objects and arrays via Proxy, with lazy per-key signal allocation.@zakkster/lite-channel-- cross-tab sync overBroadcastChannel: Lamport-clock LWW, reactive presence, and leader election -- the convenientisLeadersource for lite-query's fetch dedup.@zakkster/lite-router-- sub-2KB SPA router exposing pathname, query params, and route matches as signals. Pair with reactive keys for route-driven queries.@zakkster/lite-persist-- debounced, coalescedlocalStorage/sessionStoragesync. Persist the cache for instant cold-starts.@zakkster/lite-form-- headless reactive forms with hoisted schema validation. Pairs with mutations for validated submissions.@zakkster/lite-resource-- async state as a single signal. The minimal sibling to lite-query when you don't need a cache.@zakkster/lite-element*lite-virtual*lite-scene-- rendering: custom elements, list/grid windowing, Canvas2D scene graph.@zakkster/lite-raf*lite-time-- scheduling: frame-rate loop and drift-corrected wall-clock cadence, both as signals.
If you're new to the family, start with lite-signal -- every other library here is layered on top.
Tests
npm test437 deterministic tests. Run output:
# tests 437
# pass 437
# fail 0
# skipped 0The core suite (253) uses a controlled fetcher, mock clock, and mock BroadcastChannel so every test is deterministic -- no real timers, no real network, and it covers infiniteQuery cursor pagination, qc.prefetch, the persistence primitive + adapter (with a dependency-free dehydrated-cache corruption matrix), and the devtools feed qc.inspect (all 31 event types, the 10-key monomorphic shape + pooled-reuse identity, two-seam independence, and throwing-hook containment). The optional entry points add 31 (/await) and 24 (/stream) tests, the latter driving a manually-pumped async iterator through every termination path; the 2.0 shared-streams work adds 43 cross-tab shared-stream tests (the 7 named failover cells F1-F7, the epoch/clientId/seq projection gate, latest + buffer projection with differential parity against pipeToSignal, the watchdog under a lying oracle, promotion/adopt/abdication, the 3 new feed types, and the LS4 writer-slot swap) and 28 offline mutation queue tests (the opt-in dispatch ladder, replay ordering + per-item results + single-flight + dropQueued, the persistence seam with whole-queue drop, and the at-least-once crash boundary) and 6 falsy-rejection tests (the ON-3 breaking fix); the 2.2 freshness work adds 35 refetchInterval tests (option validation, the single-scanner mechanics -- one timer, min-period, on-or-after-due, refcount -- the watcher-seam lifecycle, the "interval" dispatch reason, the entry-removal disarm on clear/removeQueries (QD-1), the re-entrant teardown + infiniteQuery door rejections (QD-2), and the G4 leader + 3-follower shared-polling truthfulness with leaderless liveness) and 11 keepPreviousData tests (validation, the hold across a reactive key swap, isPlaceholder(), entry-truth in status/loading/getQueryData/dehydrate, and OFF-path byte-identity); and 6 repo drift guards keep the shipped files ASCII-clean, the documented surface in sync with the real exports, and the runtime VERSION const equal to package.json. See test/harness.js for the mocks.
Every entry point exports VERSION -- lite-query's own version string, the single runtime version source. It lives in Query.js, is re-exported by /stream and /await, and test/version-sync.test.js asserts it equals package.json.
Browser support
Modern browsers with BroadcastChannel and AbortController -- anything from 2020 forward. Falls back gracefully when BroadcastChannel is unavailable (cross-tab is just a no-op).
License
MIT (c) Zahary Shinikchiev. See LICENSE.
