React Query-flavored caching, stale-while-revalidate, retry, request dedupe
and optimistic updates for htmx — as a single ~7.1 kB
extension. No build step, no client data store, no API changes: everything is
opt-in hx-* attributes.
Supports htmx 2.x and htmx 4.x. While htmx 4 is prerelease, the
verified range begins at 4.0.0-beta6
(htmx.org >=2.0.0 <3 || >=4.0.0-beta6 <5).
Landing page · htmx 4 migration guide · Production recipe · AI agent guide · Interactive demo
htmx already owns HTML rendering and server interaction. htmx-query adds the small amount of client-side coordination that repeated requests usually need, without introducing a component framework or a second data model.
- HTML-first: caches the exact server-rendered fragment htmx would swap; your server remains the source of truth.
- Opt-in and incremental: add one attribute to one GET request; existing htmx requests and extensions continue to work untouched.
- Fast repeat views: fresh cache hits render without another request; stale entries can render immediately while revalidation runs in the background.
- Fewer duplicate requests: identical in-flight GETs are collapsed into a single network request.
- Safer defaults: POST retries are disabled, sensitive
Varyheaders are rejected, andno-store/privateresponses are not cached. - Small integration surface: CDN, ESM, and CommonJS builds, plus bundled TypeScript declarations. The minified brotli bundle is kept below 7.5 kB.
- Observable behavior: cache events, invalidation events, hit-rate metrics,
stale-error metrics, and a bounded
debug()view are available when needed.
- The cache is in-memory and page-scoped by default.
configure({ persist: true })mirrors it intosessionStorageso a full navigation can restore it, but that is still per-tab: it is not an offline cache and never shares cached HTML across tabs.configure({ crossTab: true })shares invalidation only. - It stores HTML, not normalized data.
htmx.query.putseeds an entry, but there is no query graph, offline mutation queue, or cross-view sharing of one response. - Cache keys are request-oriented. If two views need different HTML, give them
different
hx-swr-keyvalues or keep them as separate requests. - The default cache is bounded to 100 entries, 1 MiB total, 256 KiB per entry,
and 16
hx-selectvariants per entry. Older entries/variants are evicted when those limits are reached. - Optimistic updates are HTML templates. Complex rollback, validation, and conflict resolution still belong in application code.
- Native drag events are only a demonstration. Production touch and keyboard interactions may need a dedicated accessible drag-and-drop library.
- Cached swaps bypass later htmx
transformResponseprocessing. Do not cache elements that depend on another extension transforming every response. - htmx 4 support currently follows its prerelease API and is pinned to the tested beta floor until htmx 4.0.0 is stable.
Choose htmx-query when the server owns rendered HTML and you need bounded, repeat-request performance. Choose a normalized client data library or SPA architecture when you need offline state, cross-view mutations, persistent storage, or rich client-side conflict handling.
<script src="https://unpkg.com/htmx.org@4.0.0-beta6"></script>
<script src="https://unpkg.com/htmx-query@0.2.0"></script>
<body>
...
</body>htmx 4 activates registered extensions globally. For htmx 2, load
htmx.org@2.0.10 and add hx-ext="query" to the body or the subtree that
uses htmx-query.
For production, pin exact versions and add Subresource Integrity hashes
(integrity="sha384-..." crossorigin="anonymous") to both script tags —
floating versions cannot be integrity-checked.
Or with a bundler:
npm install htmx-query htmx.orgimport htmx from 'htmx.org';
import { register } from 'htmx-query';
register(htmx);The package exports ESM (import), CommonJS (require), and a browser IIFE.
The IIFE self-registers when window.htmx is already present. For a CDN
deployment, pin both versions and use the SRI values generated beside the
published artifacts (dist/*.sri).
- Load a supported htmx version and htmx-query. htmx 4 needs no activation
attribute; htmx 2 requires
hx-ext="query"on the nearest inherited container. - Add
hx-swr="TTL"only to cacheable GET fragments. Start with a short TTL and confirm the response is public or otherwise safe to reuse. - Add
hx-trigger="..., hq:invalidated from:body"to lists that should refresh after a mutation. - Invalidate after a successful mutation, or let the server send an
HX-Cache-Invalidateresponse header. - Add retry, optimistic, prefetch, or header-vary behavior only where the endpoint semantics justify it.
The smallest useful integration is:
<body>
<ul hx-get="/todos"
hx-trigger="load, hq:invalidated from:body"
hx-swr="30"></ul>
<form hx-post="/todos"
hx-on:htmx:after:request="if (event.detail.ctx.response.status < 400) htmx.query.invalidate('/todos')">
<input name="text" required>
<button>Add</button>
</form>
</body>The example uses the htmx 4 event name. With htmx 2, add hx-ext="query" and
use hx-on::after-request with event.detail.successful, or invalidate through
the version-neutral HX-Cache-Invalidate response header.
For production hardening—SRI, cache headers, account namespaces, CSRF, and deployment checks—see docs/production.md.
<!-- SWR: instant cached render, background refresh, fresh for 30s -->
<div hx-get="/todos" hx-trigger="load" hx-swr="30"></div>
<!-- retry: 3 attempts, exponential backoff starting at 1s -->
<div hx-get="/flaky" hx-trigger="load" hx-retry="3"></div>
<!-- optimistic: template shows instantly, reverted on error -->
<form hx-post="/todos" hx-target="#list" hx-swap="beforeend"
hx-optimistic="#pending">...</form>
<template id="pending"><li class="opacity-50">Saving…</li></template>
<!-- refetch when the todos cache is invalidated -->
<ul hx-get="/todos" hx-trigger="load, hq:invalidated from:body" hx-swr="60"></ul>// after a mutation elsewhere (e.g. an HX-Trigger header handler):
htmx.query.invalidate('/todos'); // drops matching entries + fires hq:invalidatedPrefetch is off by default. Add it only to a same-origin GET that already has
hx-swr; a single best-effort request fills the cache and never swaps the
element. hover covers pointer users, focus covers keyboard users — list
both for parity. visible prefetches when the element scrolls into view,
which suits below-the-fold links:
<a href="/reports" hx-get="/reports" hx-swr="60"
hx-swr-prefetch="hover focus">Reports</a>
<a href="/archive" hx-get="/archive" hx-swr="60"
hx-swr-prefetch="visible">Archive</a>Use native drag events or an accessible drag-and-drop library to move rows in the DOM, then submit the ordered IDs through htmx. On success, invalidate the cached list; on failure, restore the captured DOM order in the drag script.
<ol id="tasks" hx-get="/tasks"
hx-trigger="load, hq:invalidated from:body" hx-swr="60"></ol>
<form id="reorder" hx-post="/tasks/reorder" hx-swap="none"></form>Do not enable hx-retry-unsafe for reorder POSTs unless the endpoint is
idempotent and uses an idempotency key. Native drag events alone are not a
complete accessible solution: provide keyboard move controls or use an
accessible drag-and-drop library in production.
| Attribute | On | Meaning |
|---|---|---|
hx-swr="TTL" |
GET elements | Cache the response. Cached copy renders instantly on later requests; request is cancelled while the entry is younger than TTL seconds, otherwise it revalidates in the background. hx-swr="0" = always stale, always revalidate. Also opts the element into dedupe. |
hx-swr-key="key" |
GET elements | Override the cache key (default get:<final URL>). |
hx-swr-vary="Header, ..." |
GET elements | Opt in safe request-header values as cache-key dimensions, for example Accept-Language. Cookie and Authorization are rejected. |
hx-swr-prefetch="hover focus visible" |
GET elements with hx-swr |
Token list. One same-origin, best-effort request per element that populates the cache without swapping it: hover for pointer users, focus for keyboard users, visible when the element scrolls into the viewport. visible needs IntersectionObserver and is inert without it. |
hx-retry="N" |
any request | Retry failed requests up to N times (maximum 10). |
hx-retry-delay="ms" |
with hx-retry |
Base backoff delay, default 1000. Delay = base * 2^(attempt-1), capped at 10× base and 30 seconds; equal jitter spreads retries. A seconds or HTTP-date Retry-After header overrides it before the same cap. |
hx-retry-unsafe |
with hx-retry |
Allow retrying non-GET verbs (off by default — retrying a POST can duplicate a write). |
hx-optimistic="#tpl" |
mutating elements | Append the <template>'s content to the target immediately; restored before the real response swaps in, and on error. |
| Event | Fired on | When |
|---|---|---|
hq:invalidated |
body (bubbles) |
After htmx.query.invalidate(prefix). Detail carries { prefix, mode, count } — the invalidated-entry count makes server-driven invalidation debuggable. Listen with hx-trigger="hq:invalidated from:body" to refetch. Elements whose cache was not invalidated short-circuit on their still-fresh entry — no wasted requests. |
hq:prefetch |
body (bubbles) |
An explicit prefetch completed or was skipped. Detail is `{ action: 'success' |
hq:retryExhausted |
the element (bubbles) | All retry attempts failed. |
hq:staleError |
body (bubbles) |
A stale fragment was rendered, then its background revalidation failed. Detail includes key and HTTP status. |
hq:cache |
body (bubbles) |
Cache lifecycle event; detail.action is one of hit, miss, store, evict, skip, or clear. |
htmx.query.invalidate(prefix); // backwards-compatible substring invalidation
htmx.query.invalidate('/todos', { mode: 'path' }); // /todos, /todos/:id, and /todos?… only
htmx.query.clear(); // empty the cache
htmx.query.peek(); // copy of the cache Map (debugging)
htmx.query.stats(); // cache bytes, hitRate, staleErrors, and dedupe counts
htmx.query.debug(); // read-only stats plus cache keys
htmx.query.resetMetrics(); // reset diagnostic counters without clearing entries
htmx.query.setNamespace('acme'); // scope keys to an account; changing it clears old entries
htmx.query.configure({ cacheEvents: ['evict', 'skip'] }); // true, false, or event-action filter
htmx.query.configure({ cache: { maxEntries: 200, maxCacheBytes: 2 * 1024 * 1024 } }); // resize cache bounds; shrinking evicts immediately
htmx.query.configure({ persist: true }); // mirror the cache into sessionStorage for this tab
htmx.query.configure({ crossTab: true }); // propagate invalidation to other tabs in the same namespace
htmx.query.put('todos', '<li>seeded</li>', { ttl: 60 }); // seed/refresh an entry (hx-swr-key or 'get:/path' form)
document.body.addEventListener('hq:cache', (event) => {
console.debug(event.detail); // { action: 'hit' | 'miss' | 'store' | 'evict' | 'skip' | 'clear', ... }
});A successful server response can invalidate entries without handwritten client
code. Send JSON in HX-Cache-Invalidate; an object or array is accepted:
HX-Cache-Invalidate: {"path":"/todos","mode":"path"}- Only GETs on elements with
hx-swrare cached or deduped. Everything else passes through untouched. htmx-query's ownhx-swr*/hx-retry*attributes are read from the requesting element directly (no inheritance);hx-selectfollows htmx's inheritance on cached swaps — closest ancestor,data-hx-select,"unset",hx-disinherit, andhx-inheritunderhtmx.config.disableInheritance— matching the network path. stale-while-revalidate=Non a response bounds how long past origin freshness (the longer ofhx-swrandmax-age/Expires, per RFC 5861) its stale HTML may still render; beyond that the revalidation runs without a stale swap.stale-if-error=Nlets rendered stale HTML stand through a failed revalidation without anhq:staleErrorevent. Both are server-controlled and combine with ETag validation — a304that follows a refused or skipped stale render swaps the validated entry in.- Cached swaps bypass other htmx extensions'
transformResponse(htmx runs it after the point where the response is cached). Avoidhx-swron elements that depend on a transforming extension. - Error responses, empty bodies, and responses containing
hx-swap-oobare never cached. htmx.query.putstores markup verbatim and it will be swapped into the DOM on the next cache hit. Only seed trusted, server-rendered HTML — never user-supplied strings.- Conditional revalidation sends
If-None-Matchwhen the cached response carried anETag, and falls back toIf-Modified-Sincewhen it carried onlyLast-Modified. Never both — anETagis the stronger validator. configure({ persist: true })writes cached HTML tosessionStorage, where any script on the origin can read it for the lifetime of the tab. Set a namespace for per-user content and clear it on sign-out, exactly as you already must for the in-memory cache. Responses markedno-storeorprivatenever enter the cache, so they are never persisted.configure({ crossTab: true })sends only{ namespace, prefix, mode }over aBroadcastChannel; cached HTML never leaves the tab. A received invalidation fires the normalhq:invalidatedevent and is never rebroadcast.- Responses with
Cache-Control: no-storeorprivateare never cached. Parameterized forms such asprivate="Set-Cookie"are also rejected. Responses that vary on request headers other thanHX-Requestare skipped, because those headers are not part of the cache key. Usehx-swr-varyonly for non-sensitive headers you explicitly want to partition, such asAccept-Language. no-cachevalidates before every reuse;max-agecaps the configuredhx-swrTTL; a stalemust-revalidateresponse waits for validation rather than rendering stale HTML.Ageand apparent age fromDatereduce client freshness. WithoutCache-Control: max-age,Expiressets an origin-relative lifetime.- Browsers do not expose
Set-Cookieresponse headers to JavaScript. Mark personalized endpointsCache-Control: privateorno-store; do not rely on a browser extension to infer that a response set a cookie. A stale entry with anETagis revalidated withIf-None-Match; a304keeps the displayed HTML and refreshes its cache age. - Cached swaps honor the first token of the element's
hx-swap(defaultinnerHTML). - Duplicate in-flight GETs (same key) are collapsed to one request; the waiters render from cache when it lands, and are dropped on failure.
- A retrying element bypasses the cache, so a retry can never be short-circuited by the entry it is refreshing.
- The cache holds at most 100 entries and 1 MiB of raw plus selected HTML (oldest entries evicted), and lives for the page's lifetime — navigation clears it. An entry, including retained variants, cannot exceed 256 KiB.
- Each cached response retains at most 16
hx-selectvariants (oldest evicted). A variant that exceeds the byte budget is still used for its current swap but is not retained. - For predictable first-hit latency, cache a small server fragment directly;
reserve
hx-selectfor extracting a small part of a larger response.
htmx-query swaps server-rendered HTML, exactly like htmx itself. The trust boundary is unchanged: your server's HTML is trusted. Two things to keep in mind:
- Do not point
hx-swrat endpoints that reflect unsanitized user input — the cached copy is re-inserted verbatim on every hit. hx-optimistictemplates are developer-authored markup in your page, not user data. Keep them that way.- Call
htmx.query.setNamespace(accountId)after sign-in or account switching; it scopes future keys and clears the old account's fragments.clear()remains appropriate when no account namespace is used.
The runtime is JavaScript, but the package includes src/index.d.ts for
typed register(), htmx.query, cache stats, and hq:cache events. No
TypeScript build step is required by applications that use the CDN build.
The documented attributes, register() return value, htmx.query methods,
cache statistics, and hq:* event detail shapes are the public API. Additions
are released as backward-compatible minor changes; removals or behavior changes
include a changelog entry and migration notes. The declaration file and the
consumer type test are updated with every public API change. While the package
is still pre-1.0, review the changelog before upgrading minor versions.
npm run bench reports the bounded-cache baseline. npm run bench:check
uses intentionally generous local limits (5 ms for invalidating 100 entries,
250 ms for materializing 16 selectors, 25 ms for 1,000 prefetch cache hits) to catch major regressions in CI
without turning normal machine variance into failures.
The supported runtimes are htmx 2.x and htmx 4.x in modern browsers. CI runs the unit suite plus real-browser checks in Chromium, Firefox, and WebKit against representative versions from both majors. htmx 4 remains pinned to the verified beta until its stable release.
npm run build writes a SHA-384 SRI file next to each script-tag artifact:
dist/htmx-query.min.js.sri (what unpkg serves by default via the package's
unpkg field) and dist/htmx-query.iife.js.sri. Pin a published version and
copy the value matching the file you actually load into the script tag's
integrity attribute. The release workflow uses npm Trusted Publishing
(OIDC); before the first release, an owner must bind this GitHub repository
and release workflow at npmjs.com. It intentionally has no npm
automation-token fallback. The step-by-step owner checklist lives in
RELEASING.md.
This is not a client-side data store. There is no setQueryData, no offline
mutation queue, no cross-view sharing of one cache entry, no devtools cache
inspector — those need a JSON data cache, which is React Query territory. If
your app needs them, that's the signal to reach for a SPA stack, not to
extend this further.
npm run build
npm run demo # http://127.0.0.1:8484 (local-only by default)
# Optional: use another local port
PORT=8485 npm run demo
# Only when intentionally sharing on a trusted network
HOST=0.0.0.0 PORT=8485 npm run demoThe local demo serves versioned JavaScript assets with a one-hour browser
cache. Its HTML and mutation responses are no-store; its public SWR list
responses use public, max-age=60, so their server freshness contract agrees
with the visible hx-swr demonstration.
The demo server is deliberately not a production server: it uses in-memory state, accepts only same-origin htmx mutations, and applies a small request body limit. Production applications need their own authentication, authorization, CSRF protection, rate limiting, and durable storage.