Staleness Signal
The staleness signal tells the user when the item-card data they are looking at no longer matches what the backend would return for the same request. It is the read-side complement to the cache documented in Data Flow and Caching: the cache makes reads cheap by serving from memory; the signal makes them honest by warning the user when that memory has fallen behind. The result is the stale-data banner with Refresh and Dismiss controls that appears at the top of the item-details panel.
This page is the system reference for that mechanism. The product-level promise is recorded in Concurrent-Edit Awareness; the design decisions that shape this section are recorded as ADRs (linked at the bottom).
Layers at a glance
Section titled “Layers at a glance”The signal layers a small set of pieces on top of the ItemCardsProvider cache:
| Layer | Role |
|---|---|
itemStaleBus | A per-tab event bus over BroadcastChannel('arda-item-stale'). Carries { eids: string[] } messages. Tab-local subscribers receive the message synchronously; sibling tabs of the same browser receive it through the channel. |
| Producers | Surfaces in the SPA that know a user action made some item stale: item create / update / delete, card-state events from grid quick-actions, scan modals, the order-queue page handlers, the explicit Refresh button, and the focus / visibility and poll-driven background paths. |
| Consumer | A single subscriber inside ItemCardsProvider that drains bus deliveries into the provider’s microtask coalescer (enqueueStaleRefresh), which issues a single batched refreshCardsForItems(eids) call per tick. |
useFreshRead | The detail-panel hook that snapshots the rId set on mount and re-evaluates it after its own refresh resolves. Owns the isStale boolean the banner subscribes to. |
pollTimer | An interval on ItemCardsProvider that fires markItemStale(Array.from(mountedEids)) while the tab is visible and at least one subscriber is mounted. Carries the cross-process signal (different browsers, different users) within a bounded SLA. |
The component diagram below shows how these pieces wire together inside one tab and how the cross-tab channel crosses the tab boundary. Producers publish to the bus; the bus’s tab-local notify and inbound channel message both reach the provider’s consumer; the consumer feeds the microtask coalescer, which issues one batched refresh; the refresh result updates the cache; useFreshRead’s rId-set diff flips isStale; the banner subscribes.
The bus
Section titled “The bus”itemStaleBus is constructed once per ItemCardsProvider instance and disposed on unmount. Its public surface is two methods, both of which take string | readonly string[] | undefined. Empty, falsy, and missing inputs are no-ops on both methods, so producer call sites do not pre-guard.
| Method | Local consumer | Cross-tab post | Use when |
|---|---|---|---|
markItemStale(eid) | yes | yes | Producer does not own a local refresh callback. The bus is the only refresh trigger on the active tab. |
markItemStaleRemoteOnly(eid) | no | yes | Producer already owns a local refresh path (e.g., the Refresh button’s own refresh call). Using markItemStale here would double-fire the same refresh on the active tab. |
The local consumer call exists because BroadcastChannel does not echo postMessage back to the sending context. Without the explicit local notify, the publishing tab would miss its own signal.
Capability fallback
Section titled “Capability fallback”BroadcastChannel is missing or disabled in some browser configurations. When the global is undefined at bus construction, the bus falls back to in-tab-only delivery: markItemStale still calls the local consumer, but the cross-tab post is a no-op. The same fallback is what the unit test harness uses when it explicitly removes the global; production code on browsers without BroadcastChannel simply loses the same-browser cross-tab path and continues to rely on the poll-driven cross-process path.
Producers
Section titled “Producers”The producer set is fixed by the design — every surface that knows a user action made some item stale publishes through the bus.
| Surface | Method | What it publishes |
|---|---|---|
ItemFormPanel save | markItemStale | The created or updated item’s entity id, after the BFF returns success. |
useDeleteItems bulk delete | markItemStale | The array of deleted item entity ids, after every per-item DELETE succeeds. |
CardStateDropdown state change | markItemStaleRemoteOnly | The owning item’s entity id, after the kanban-card event endpoint returns ok. The dropdown invokes its own local refresh callback, so the bus only crosses tabs. |
columnPresets QuickActionsCell (shopping cart) | markItemStale | The row’s item entity id, after add-to-order-queue succeeds. |
ScanModal add-to-queue / receive | markItemStale | The card’s owning item entity id. |
CardPreviewModal add-to-queue / receive | markItemStale | Same. |
Order-queue page handlers — handleStartOrder, handleSendEmail, handleCopyToClipboard, handleCompleteOrder, handleRemoveFromQueue | markItemStale | Item entity id (single) or array (batch). |
ItemDetailsPanel Refresh button | markItemStaleRemoteOnly | The open item’s entity id. The button also calls useFreshRead’s own refresh(), which is the active-tab refresh. |
useRefreshOnFocus | markItemStale | The union of grid-displayed item entity ids and the open detail-panel item, on visibilitychange → visible. |
pollTimer tick | markItemStale | The full contents of mountedEids. Carries the cross-browser signal. |
The order-queue page passes the item entity id, not the kanban-card entity id. The two are distinct in that area of the app, and the bus’s consumer queries the cache by item entity id; sending a card id would match no cache entry and produce no refresh.
Producers that own their own local refresh
Section titled “Producers that own their own local refresh”Two producer sites — the detail-panel Refresh button and the CardStateDropdown — own their own local refresh callback that runs on the active tab. If they used markItemStale, the local-notify branch of the bus would queue an enqueueStaleRefresh on the same tick that the producer’s local refresh fires, producing two back-to-back fetches for the same data on the active tab. These sites use markItemStaleRemoteOnly instead: the cross-tab post still reaches sibling tabs, but the local consumer is not invoked, so the active tab refreshes exactly once.
The consumer
Section titled “The consumer”ItemCardsProvider registers a single consumer on bus construction. Every delivered eid set — whether it came from a local markItemStale call or from an inbound BroadcastChannel message from a sibling tab — is forwarded into enqueueStaleRefresh. The coalescer batches all eids enqueued within one microtask into a single refreshCardsForItems(eids) call. The mechanics are documented in Data Flow and Caching — TTL and read-side coalescing and in ADR-002: Cache-Invalidation Coalescing.
The coalescer is what keeps a burst of producer activity (e.g., a bulk delete that publishes ten eids and an inbound channel message that delivers two more in the same tick) from issuing twelve sequential refreshes. They all merge into one batched fetch.
useFreshRead — the consumer-facing diff
Section titled “useFreshRead — the consumer-facing diff”useFreshRead(eid) is the detail panel’s read hook. On mount and on eid change it:
- Snapshots the
rIdset of whatever cards are currently in the cache foreid. - Registers
eidin the provider’smountedEidsset (so the poll timer sees it). - Fires
refreshCardsForItem(eid)and awaits the result. - When the result resolves, diffs
rIdSet(fresh)againstsnapshotRef. On mismatch, setsisStale = true.
The hook also exposes a refresh() method that re-snapshots the now-current rId set, fires another refreshCardsForItem, and clears isStale only when the second fetch matches the new snapshot. This is the path the banner’s Refresh button drives.
The diff is on the wrapper rId (the kanban-card record id), not on payload.eId (the kanban-card entity id). The wrapper rId changes every time the card is mutated server-side, which is what the user needs to see; payload.eId is the stable identity that survives mutations. See Conflict Resolution for the same distinction in the bulk-action preflight.
What the diff does and does not flip
Section titled “What the diff does and does not flip”isStale flips through useFreshRead’s own promise tracking only. Bus-driven refreshes update the cache for every subscriber to read from, but they do not flip the panel’s isStale flag on their own. The banner appears on:
- Detail-panel mount when the post-mount refresh resolves with an rId set that differs from the cache snapshot taken at mount time. This is what users see when they open a panel for an item another browser has already mutated.
- An explicit
refresh()call when the fetched rId set differs from the just-taken snapshot.
This is intentional: the banner is a panel-scoped signal anchored to the panel’s own view of the data. The cache may advance under it; the banner only appears when the panel’s own refresh path resolves with evidence that the user is looking at a different state than the backend.
Two scenarios, two paths
Section titled “Two scenarios, two paths”Same-browser cross-tab via BroadcastChannel
Section titled “Same-browser cross-tab via BroadcastChannel”When User A edits an item in tab 1, a sibling tab 2 (open on the same item) needs to know within milliseconds. The cross-tab path delivers this through BroadcastChannel: the publisher posts a message that the other tab’s bus receives synchronously on the channel’s message event.
Latency is bounded by BroadcastChannel’s in-process message dispatch plus one microtask flush plus one BFF round-trip. In practice this is sub-second on the local network and dominated by the BFF call.
Cross-browser via pollTimer
Section titled “Cross-browser via pollTimer”When User A edits in their browser and User B is on a different browser entirely (different machine, different network), there is no in-process channel to carry the signal. The cross-process path delivers it through a bounded poll: pollTimer ticks every NEXT_PUBLIC_ITEM_CARDS_POLL_MS (default 120 s) on every active tab and, when the tab is visible and mountedEids is non-empty, publishes markItemStale(Array.from(mountedEids)) through the local bus. The bus’s consumer refreshes the cache; the next time the user’s useFreshRead does its own refresh — typically the next time the user touches the panel — the rId-set diff flips isStale to true and the banner appears.
Worst-case latency is one poll interval. The default 120 s value is the operational compromise documented in ADR-001: Frontend Cache-Invalidation Mechanism; test environments override it via NEXT_PUBLIC_ITEM_CARDS_POLL_MS.
The poll is gated by two conditions on every tick:
- The tab’s
document.visibilityStatemust be"visible". Hidden tabs do not tick. This is the simpler half of the cost ceiling — a backgrounded tab can sit indefinitely without producing any network traffic. mountedEidsmust be non-empty. A tab with no subscribed item cells or detail panel does not need refresh signals; the tick exits early.
Operational tuning and kill switch
Section titled “Operational tuning and kill switch”NEXT_PUBLIC_ITEM_CARDS_POLL_MS accepts any finite numeric value:
- Positive values set the poll interval directly.
- Zero or negative values are the kill switch. They reach the provider’s
effectivePollInterval <= 0guard, which exits the poll-installation effect entirely. The cross-tab path keeps working; only the cross-process polling path is disabled. Use this when an incident requires shedding the polling traffic without redeploying functional changes. - Non-numeric values fall back to the 120_000 ms default.
The value bakes into the Next.js bundle at module evaluation time and requires a redeploy to change.
Robustness properties
Section titled “Robustness properties”A few properties are worth calling out because they are easy to assume incorrectly:
- Cycle safety. Cache-update completions do not publish to the bus. Only producers (write-originating user intent, focus / visibility events, poll ticks) call
markItemStale. The consumer reads from the cache and writes to the cache, but it never publishes. Without this property the bus would amplify under sustained load. - No amplification under burst. The microtask coalescer absorbs every
markItemStalecall within one tick into a single batched refresh, regardless of how many sites publish. useBulkSelectionStaleGuardnon-interference. The bulk-delete preflight described in Conflict Resolution runs its own refresh through the samerefreshCardsForItemscontract. Concurrent bus traffic for unrelated eids does not change the guard’s snapshot vs refresh verdict for the items it is guarding.refreshItemCardswindow-event coexistence. The legacyrefreshItemCardswindow event (dispatched by surfaces such asManageCardsPanel) and the bus are independent paths. Both can fire for the same underlying signal without producing more than two refreshes on the active tab: one immediate per the window-event handler, and one batched per the bus’s microtask coalescer.- Refresh failure does not flicker the banner. When
refreshCardsForItemsresolves to the fetch-failed sentinel (transport error, non-OK response),useFreshReadleavesisStaleunchanged. A transient network failure does not cause the banner to appear and disappear on its own.
Related references
Section titled “Related references”- Data Flow and Caching — the cache shape the signal feeds.
- Conflict Resolution — the bulk-action preflight that handles selection-time staleness.
- Concurrent-Edit Awareness — the product capability.
- General Behaviours — Interactions — the user-flow scenarios this mechanism must satisfy.
- ADR-001: Frontend Cache-Invalidation Mechanism
- ADR-002: Cache-Invalidation Coalescing
- ADR-003: Concurrent-Edit Detection Strategy
Copyright: © Arda Systems 2025-2026, All rights reserved