npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

npm version Zero-GC sponsor npm bundle size npm downloads npm total downloads TypeScript lite-signal peer Dependencies license

~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-signal

One 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/await

The 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 settles status() === "error" with the value held verbatim in error() -- previously a falsy rejection was mis-read as success. Branch on status(), never on error() 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, or queue: true without a function offline / string name / array queueKey, throws TypeError at construction (nothing built). An offline() that throws or returns a non-boolean rejects mutate() with err.code === "LQ_OFFLINE_ORACLE" -- an unverified connectivity state never silently picks a branch. A full queue (maxQueue, default 100) rejects LQ_QUEUE_FULL, never a silent drop.
  • Durable. Wire queueSave / queueLoad sibling thunks on persistQueryClient (both or neither); the queue rides the same version stamp and throttle window as the cache. On boot handle.queueRestored resolves the restore outcome; a mismatched or corrupt queue drops whole and says so (on the promise and a queue:drop feed 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. tries is 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 record id is 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 first replayQueue -- if the cache has not re-seeded its entries first, every restored record drops as entry-missing (a drop is a removal, so the mutation is lost).
  • qc.queueSize() reports the durable count (0 when the queue was never touched -- null is 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 are query()'s job. Wrapping a query() fetcher in withRetry double-owns the retry policy -- don't.
  • delay(ms, opts) advances wall clock, not a mock clock. It is a real, abort-aware setTimeout promise; in tests prefer driving state directly (setQueryData / a manual iterator) over sleeping real time.
  • createAwaitScope(ctrl?) binds N signal-aware awaiters to one AbortController (owns a fresh one when omitted; borrows when passed, and scope.abort() then throws). Upstream decisions/0007 owns the documented contract; we only re-export it.
  • fromPromise vocabulary is final -- lite-await decisions/0009 verdict = 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();   // idempotent

Every 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 !== null test. The feed is also independent of persistQueryClient'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() vs fetching() -- loading() is true only on the initial fetch (no data yet). fetching() is true on any fetch, including background revalidation. Use loading for spinners, fetching for subtle indicators.

A few things to know that aren't obvious from the API:

  • loading() vs fetching() -- loading() is true only on the initial fetch (no data yet). fetching() is true on any fetch, including background revalidation. Use loading for spinners, fetching for subtle indicators.
  • staleTime: 0 (the default) means every fresh observer attach triggers a refetch. If you want "cache hits don't refetch", set staleTime: Infinity or 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 / removeQueries propagate.
  • Same-entry watcher re-runs don't refetch. If your reactive key function 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 rapid mutate() calls each get their own promise, but the mutation's state signals (data, status, error) reflect only the latest one. Gen-guarded.
  • onSettled always fires. Even if onMutate / fn / onSuccess / onError threw. Callback errors are contained so a buggy callback can't lock the user's UI.
  • AbortSignal.reason carries 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:

  1. A follower tab needs data. Instead of fetching, it broadcasts a fetch-req and shows a loading state.
  2. 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.
  3. 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.
  • sharedFetch requires both crossTab: true and a valid isLeader function. Without isLeader, 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 supply isLeader from 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, live

How it holds together:

  1. One connection, transparently. The leader opens the iterator; followers receive frames as BroadcastChannel messages. data(), count(), and droppedCount() read the projected window exactly as a local stream reads its own -- a slow follower bounds itself through its own maxBuffer and counts its own drops.
  2. 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:gap in the feed).
  3. 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.
  4. 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-false isLeader oracle.
  5. 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 BroadcastChannel playbook as lite-channel, and its fetch dedup needs only an isLeader oracle, for which lite-channel is the convenient source.
  • Its cache can persist through lite-persist for instant cold-starts.
  • Its mutations pair naturally with lite-form for validated submissions.
  • Everything renders through lite-element, lite-scene, or lite-virtual without 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 over BroadcastChannel: Lamport-clock LWW, reactive presence, and leader election -- the convenient isLeader source 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, coalesced localStorage/sessionStorage sync. 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 test

437 deterministic tests. Run output:

# tests 437
# pass 437
# fail 0
# skipped 0

The 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.