Freshness and Live Data FULL

Every query declares when its data should refresh. The options form a ladder, from a single fetch to a server push stream, and every rung shares one state surface and one set of safety rules.

The Refresh Ladder

RungDeclarationBehavior
Once'once' (default)Fetch when first observed. Refresh only when asked.
Poll30Refetch every N seconds while the query is on screen.
Conditional poll'etag:60'Re-check every N seconds with a conditional request. The server answers 304 when nothing changed, and no data moves.
Focus'focus'Refresh when the tab regains focus.
Reconnect'reconnect'Refresh when the browser comes back online.
Freshness window'fresh:30' v1.5+Focus, reconnect, and stream-reopen catch-ups skip their refetch while the last sync is younger than N seconds.
Server push'sse'Hold an EventSource open and apply what the server sends.

Rungs combine as an array. A typical dashboard query reads refresh: ['focus', 'etag:60'], which refreshes when the user returns and re-checks cheaply in the background.

The freshness window stops event rungs from firing a request every time. Flicking between tabs should not send one request per flick, so refresh: ['focus', 'fresh:30'] answers focus with the data already on screen while the last sync is under thirty seconds old. A store holding unconfirmed optimistic state refetches regardless of the window, refresh() and invalidate() always fetch, and a poll rung keeps the cadence you chose for it. The window also gates the catch-up fetch a reopened stream fires, which is what keeps a flapping connection from generating a request per reopen (see Streams in Deployment). Without fresh:N, every event fires a check.

Conditional Requests

When a response carries an ETag header, the query remembers it. Every conditional re-check sends If-None-Match, and a 304 answer costs a round trip with no body, parsing, or DOM work. Static file hosts and most API servers do this without configuration.

A Live Feed Without a Server

Liveness does not require SSE. A function source plus invalidate() turns any event-producing transport into a live query. The example below drives one from a simulated in-page feed; in your app it could be a WebSocket or a sync engine.

Live Example A ticker driven by invalidate() Open Full Example ↗
The pattern:
wildflower.query('prices', {
    from: () => feed.snapshot(),   // any transport, any shape source
    key: 'id'
});

// Each transport event invalidates; the engine re-runs the source
// and patches only the rows that changed.
feed.onTick(() => wildflower.getQuery('prices').invalidate());

Server-Sent Events

The 'sse' rung holds a browser-native EventSource open and lets the server decide when data changes. It understands two kinds of message.

  • A message with a JSON body is the new result set. It applies directly, and any fetch already in flight is discarded in its favor. It passes through the query's select exactly as a fetch response does, so a push must carry the same shape the from URL returns: an envelope on the fetch means an envelope on the stream.
  • An empty message is an invalidation signal. The query re-checks its from URL conditionally.
wildflower.query('liveItems', {
    from: '/api/items',          // initial load and catch-up fetches
    refresh: 'sse',
    stream: '/api/items/stream'  // dedicated event-stream endpoint
});

The stream option exists because proxies and gateways often mishandle a URL that serves both a fetch and an event stream. When your setup does serve both from one URL, omit stream and the query connects to from.

A stream URL resolves :token segments the same way from does, and the stream waits on the same tokens the reads wait on. While a token is unresolved the rung waits, exactly like a read on the same URL. When params change the resolved stream URL, the query closes the old connection and opens the new one, and the fetch that sent those params serves as the catch-up.

Declared headers apply to reads and writes and never to the stream, because EventSource cannot send them, and development builds warn with WF-984 when a query with headers opens one. Authenticate a stream with a token in its URL query string, or with cookies on a same-origin stream.

Live Example Server push, and what interruption looks like Open Full Example ↗
What an interruption looks like. When the stream drops, the query sets isStale and syncError, keeps every row on screen, and lets EventSource reconnect on its own. The reopen triggers one conditional fetch to catch up on anything missed, unless a declared fresh:N window says the data is recent enough to wait.

Streams in Deployment

SSE is simple in the browser and unreliable between the browser and your server. None of the failures below show up as an error.

The HTTP/1.1 connection budget. Over HTTP/1.1, browsers allow about six connections per origin, shared across every tab. Each 'sse' query holds one open per tab for as long as it is on screen. Three streaming queries in two tabs can exhaust the origin, and the first thing to break is the query's own reads, which queue behind the streams and appear to hang. Serve streams over HTTP/2, where connections multiplex and the budget disappears, or keep to one stream per app and fan updates out with invalidateQueries. It is also why an app that streams cleanly on localhost can deadlock in staging with a few tabs open.

The browser reconnects, and the server sets the delay. When a stream drops, the browser reconnects on its own schedule, and engines differ in how long they keep trying. The framework never runs that loop. It records the interruption (syncError, isStale), keeps the rows, and leaves the reconnection to EventSource. The reconnection delay is set by the server, through the SSE protocol's retry: field sent in the stream body. That field shares a name with the query's retry option and has nothing to do with it; the option governs read fetches on the retry ladder and never touches the stream.

Each successful reopen fires one conditional catch-up fetch, covering whatever the stream missed while it was down. A declared fresh:N window applies here exactly as it does to focus and reconnect. While the last confirming sync is younger than N seconds, the reopen skips its fetch. That matters when a proxy recycles idle connections on a timer, which otherwise costs one catch-up request per client per recycle. Messages themselves count as syncs, so a busy stream suppresses the redundant fetches while a quiet one ages out of the window and catches up. An unconfirmed patch() from before the outage refetches regardless of the window.

The catch-up is a snapshot, and gaps in an accumulated list stay gaps. For a replacement list the catch-up fetch is the complete current truth, so nothing a dropped stream missed is ever lost. An accumulating query is different. The fetch brings the current window, the merge keeps your accumulated tail, and an event that arrived only during the gap appears in neither. If gap completeness matters, have the server assign id: fields to its events so the browser's automatic Last-Event-ID replay fills the gap, or treat the stream as an invalidation signal only.

A stream that runs smoothly locally and arrives in bursts in production is being buffered by a proxy. Nginx and similar gateways buffer responses by default, holding events until a chunk fills, and the symptom is indistinguishable from a slow server. Turn buffering off for the stream route (proxy_buffering off, or the X-Accel-Buffering: no response header) and the events flow as sent.

When a Sync Fails: Automatic Retry

Networks drop and servers restart, so a query will eventually see a failed fetch. By default the failure is reported immediately. The first load sets error, a background refresh sets syncError, and your markup decides what to show. Declaring retry puts an automatic ladder between the failure and that report.

wildflower.query('orders', {
    from: '/api/orders',
    key: 'id',
    refresh: ['focus', 'etag:60'],
    retry: 3
});

That number is the entire configuration. Three retries run at one second, two seconds, and four seconds, doubling each time with a cap at thirty seconds. There is no policy object, jitter setting, or per-status rule to configure.

While the ladder runs, the query holds its state rather than flickering through it. Rows stay on screen, error and syncError stay unset, and the loading or stale flag persists until the ladder resolves. A success on any rung applies normally and resets the count. When the ladder is exhausted, the query sets exactly the state the failure would have set on attempt one, so markup written without retry in mind keeps working unchanged.

Live Example A flaky source, recovered by retry Open Full Example ↗
Watch the call log. The simulated server fails twice per episode. The first two attempts fail, the ladder waits one second and then two, and the third attempt returns the rows. No error state ever reaches the page.
  • A manual refresh() or invalidate() starts a fresh episode. Any pending retry is cancelled and the count resets.
  • Going offline suspends the ladder. The ladder does not spend attempts on a network that is known to be down. When the browser comes back online, the ladder resumes where it left off. This works with or without the 'reconnect' rung.
  • Superseded requests never retry. When a newer call replaces an older one in flight, the old request is aborted, and an abort is not a failure.

Lifecycle: Active While Observed

A query is active while an element bound to it is in the document. When the last one leaves, the query waits about five seconds, then goes inactive. Timers stop, listeners detach, streams close, and any request in flight is aborted. The data and the remembered ETag stay put, so if the query is observed again it shows its last results instantly and catches up with a single conditional fetch. Activation skips that catch-up when a read is already in flight, so an app that sets its route and calls refresh() before the section mounts pays one request, not two. A section that starts hidden with data-render binds nothing until it appears, so a query referenced only inside it does not fetch at init.

The grace window is what keeps this from being noticeable. Toggling a panel with data-show, switching tabs, and list churn all pass through it without tearing anything down. There is nothing to manage or configure.

Returning to a Page Already Seen v1.5+

A query keeps the rows of recently resolved request URLs in memory. Move to page 2 and back to page 1, switch a filter and switch it back, or leave a detail view and return to it, and the rows paint immediately while the query revalidates behind them. They arrive marked isStale, exactly as a restored persisted snapshot does, since they are a previous answer awaiting confirmation.

There is nothing to declare. The resolved URL is the identity, so a query whose params or :token values change is already producing the cache keys, and there is no key to manage. Only confirmed server truth is stored, so an optimistic write never enters the cache, and a repaint is skipped while any write is unsettled, for an accumulating query (whose rows span several URLs, so no single one describes them), and for the URL already on screen.

What it keeps

Fifteen URLs are held per query, under a budget of five thousand rows across them. Two bounds rather than one, because a count of URLs says nothing about what a query holds. Fifteen pages of fifty rows and fifteen pages of ten thousand are very different amounts of memory, so the pair together lets small results keep deep history while a large table keeps little. A single result larger than the whole budget is still cached, since dropping it would mean the cache never worked for large queries at all.

Both are global, and set through wildflower.config() rather than per query, so there is still no per-query cache to manage:

wildflower.config({
    queryCacheEntries: 15,      // URLs held per query; 0 turns the cache off
    queryCacheRows:    5000,    // rows retained across those entries
    queryCacheMinDwell: 0       // ms a URL must stay current to be worth keeping
});

queryCacheMinDwell is off by default. It exists for search-as-you-type, which params as a function makes natural: each debounced keystroke resolves its own URL, so a typed sentence can fill the cache with prefixes nobody navigates back to and push out the pages that do get revisited. Setting it to around a second treats a URL replaced inside that window as a keystroke rather than a view, and drops it. Leave it off unless a query's URL changes as fast as someone can type.

The cache is not a second live copy. One query holds one result set, so two views that need different slices of a resource at the same time are two named queries. The cache shortens the wait when you return to something; it does not hold two of them open at once.

Persistence: Fresh Across Reloads v1.5+

A query with persist: true keeps its last confirmed rows in localStorage. On the next visit the page paints them immediately, before any request leaves the machine, and the query revalidates against the source in the background. The restored rows are flagged isStale until the source confirms them, and the saved ETag is sent with the revalidation, so an unchanged source settles the question with a 304 and the rows you already see become the confirmed answer.

wildflower.query('orders', {
    from: '/api/orders',
    key: 'id',
    refresh: 'focus',
    persist: true      // or a string, to choose the storage key yourself
});

Only confirmed server truth is ever stored. Optimistic writes never touch the disk. While a write is in flight the save waits, and the write's own confirmation stores the converged result, so a reload mid-write shows the last confirmed state, the same guarantee the rollback machinery makes on screen. Snapshots older than a day are discarded, and that bound holds even while the device is offline. A device that comes back after longer starts clean rather than painting week-old rows as current. Keeping an app usable through long offline stretches is a service worker's job, which is the same line the write side draws around offline queues. Server-rendered pages keep their precedence, since a page that arrives with rows adopts them and the stored copy is not restored.

Restored rows answer only for the URL they came from. The snapshot records the resolved request URL at save time, and a restore compares it against the URL the query resolves on this visit. When params point somewhere else, the snapshot stays on disk and the page loads normally, so a reload on /api/articles/beta never paints rows persisted from /api/articles/alpha. A query whose route token has not resolved yet holds the comparison until its first request is ready, then paints a matching snapshot just before that request is sent. A function from has no request URL and restores whenever a snapshot exists.

persist: true derives the storage key from the query name. Pass a string instead to choose the key yourself, for example one that includes the signed-in user, so an account switch reads a different snapshot. A query without persist never touches storage.

Every confirmed arrival writes the whole snapshot, so a query that persists and streams pays that write per message. The cost is small. A thousand rows serialize and store in about a third of a millisecond, and a megabyte of them in under one, so ten messages a second spends well under one percent of the main thread. There is no throttle to configure. The limit at that size is storage rather than time. Browsers allow a few megabytes per origin, and a large result set stops saving long before it costs anything noticeable. A failed save also drops the stored snapshot, so the next load starts clean instead of restoring rows the engine can no longer confirm, and development builds warn with WF-994 on the first drop.

Versioning across deploys has a client side and a server side. The client side is automatic. The snapshot remembers which select and key produced it, and a deploy that changes either one starts that query clean instead of restoring rows shaped by the old code. The check reads your code as text, so any edit to the select function counts as a change, and an app that minifies its own scripts will start clean on every release. The server side takes care of itself on the next fetch, since changed data means a changed response. For a data-model change you want to force everywhere at once, rename the key (persist: 'orders-v2') and every client starts clean.

Persisted rows are data at rest. Anything a query persists sits in localStorage, readable by any script on your origin, and it stays on the device after the session ends. Think before persisting sensitive rows, and clear snapshots when the user signs out: wildflower.clearPersisted() removes every persisted query's snapshot (or pass names to clear specific ones) and returns the count removed. The clear holds through a short transition window, so a response already in flight at sign-out cannot write a snapshot back. It clears disk only; the sign-out's own navigation or reload takes care of what is on screen, and with the disk clear, nothing restores when it loads. For a sign-out that stays on the page, unbind the query's elements before clearing, so nothing keeps syncing on the signed-out user's behalf.
async function logout() {
    wildflower.clearPersisted();          // all persisted queries
    await fetch('/api/logout', { method: 'POST' });
    window.location.href = '/login';
}

Prefetching

Warming a query before anything displays it is one call. wildflower.getQuery('orders').refresh() fetches into the store whether or not an element is bound, so a route transition or a hover can start the request early, and the view that mounts afterward paints from the warm store instead of waiting on a round trip. There is no separate prefetch API; the handle you already have does it.