Skip to content

perf(fetchium): quiet query reactivation and focus-aware polling - #70

Merged
abedell-phantom merged 13 commits into
perf/sync-hydrate-first-fetchfrom
perf/quiet-reactivation
Oct 8, 2026
Merged

abedell-phantom merged 13 commits into
perf/sync-hydrate-first-fetchfrom
perf/quiet-reactivation

Conversation

@abedell-phantom

@abedell-phantom abedell-phantom commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Why

Returning to a screen reactivated all of its queries at once. Each one refetched if it was past staleTime, even when its data was seconds old or a live subscription had just updated it. All those refetches started in the same task, and poll() kept running while the app was in the background. In a large React Native app this made tab returns and app resumes bursty.

What changed

Every option here is opt-in. With defaults, behavior is unchanged.

  • reactivationGraceMs on QueryClient, with a per-query override in the query config. A query that reactivates with data younger than the grace is not refetched.
  • Data a subscription pushed to counts as fresh from the last push: a subscribe stream event, or a topic event sent with its topic.
  • A poll() subscription counts only through the fetches it makes.
  • Invalidation, markStale(), refetch(), network reconnects and a failed last fetch still refetch.
  • TopicQueryAdapter.sendMutationEvent(event, topic?): pass the topic so that topic's query gets credit for the push.
  • reactivationStaggerMs: reactivation refetches queued in the same task are spread evenly across the window, in activation order.
  • A staggered refetch is skipped if another fetch started on that query while it waited.
  • QueryAdapter.coalescesRequests: an adapter that sets it keeps its queries' reactivation refetches together, so they can share a request.
  • activity: { isActive(), subscribe(listener) } on QueryClient: poll() stops while inactive and resumes when active. A tick not yet due keeps its original time.
  • pollResumeJitterMs, with a per-poll poll({ interval, resumeJitterMs }) override: a tick overdue on resume, or one whose timer fired more than 1 s late, is rescheduled at a random point in the window.
  • New exported type ActivitySource.

Behavior changes

None with default options.

Known limitations

  • Fetchium can't observe foreground state itself. On React Native, wrap AppState in an ActivitySource.
  • Topic queries get push credit only from adapters that pass the topic to sendMutationEvent. Otherwise the grace measures from the last fetch.
  • With reactivationStaggerMs set, the stagger flush runs one task after reactivation.

Testing

  • New reactivation-grace.test.ts: grace defaults and per-query override, invalidation, failed fetches, push credit for streams, polls and topics, reconnects, stagger order, coalescing adapters, skipped and cancelled staggered refetches.
  • New poll-activity.test.ts: stop and resume, jitter, late ticks, listener cleanup.
  • New react/pause-reactivation.test.tsx: resuming a paused screen with and without the grace.
  • New topic-batch-delivery.test.ts: topic snapshots delivered in one task settle in one flush.

Changeset

minor: .changeset/quiet-reactivation.md, added during review so these options appear in the release notes.

Stack: #67 → #64 → #61 → #69 → #70 (this) → #71.

🤖 Generated with Claude Code

abedell-phantom and others added 4 commits October 6, 2026 13:00
Adds opt-in client options, all defaulting to current behavior:
- reactivationGraceMs (+ per-query override): a reactivating query whose
  data is younger than the grace is not refetched. Data a running
  subscription kept current counts as fresh until deactivation.
  Invalidation, refetch(), network reconnects and failed fetches still
  refetch.
- reactivationStaggerMs: reactivation refetches queued in the same task
  are spread evenly over the window; adapters that set
  coalescesRequests keep theirs together.
- activity + pollResumeJitterMs: poll() stops timers while the app is
  inactive and spreads overdue ticks (including ticks that fire over a
  second late after frozen timers) across the jitter window.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tion delivered

The grace counted any running subscription as keeping the data current
until the query deactivated. A poll() that never ticked qualified: it
restarts its full interval on every activation, so visits shorter than the
interval separated by gaps shorter than the grace chained the grace forever
and never refetched (poll 60 s, staleTime 0, grace 10 s: twenty 50 s visits
served 18-minute-old data from one fetch). The same credit covered time a
focus-aware poll spent stopped in the background.

Now only a delivery counts: a stream event through the subscription's
onEvent, or a topic event the adapter sends with its topic
(sendMutationEvent(event, topic)), and only up to the last one. A poll()
keeps data current through the fetches it makes, which already move
updatedAt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…uring the window

A staggered refetch waits up to reactivationStaggerMs and then started
unconditionally. A refetch() or poll tick in that window was aborted by it
and repeated, and a fetch that had already landed was repeated too. It now
starts only if no fetch has started since the query was queued.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ions

This change had no changeset, so its new QueryClient options would have
been missing from the release notes. No source change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@abedell-phantom
abedell-phantom force-pushed the perf/quiet-reactivation branch from 3403472 to a7fcbd3 Compare October 6, 2026 17:01
Comment thread packages/fetchium/src/QueryResult.ts
Comment thread packages/fetchium/src/QueryResult.ts Outdated
Comment thread packages/fetchium/src/QueryResult.ts Outdated
abedell-phantom and others added 6 commits October 6, 2026 19:38
- __fetchNext() counts as a fetch start, so a staggered refetch waiting in
  the window no longer resets the page it loaded.
- An aborted fetch that resolves late no longer clears lastFetchFailed, which
  may belong to the fetch that replaced it.
- A reconnect while the query was inactive now voids the grace, using a new
  NetworkManager.reconnects counter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread packages/fetchium/src/QueryResult.ts
Comment thread packages/fetchium/src/QueryResult.ts
…tion

# Conflicts:
#	packages/fetchium/src/QueryResult.ts
abedell-phantom and others added 2 commits October 7, 2026 18:43
…activation

Skip the reactivation grace when a Signal param changed while inactive, and
let a debounced fetch (e.g. invalidateQueries()) scheduled before the stagger
flush drop the query's stagger slot instead of being replaced by it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@abedell-phantom
abedell-phantom merged commit 46e004f into main Oct 8, 2026
1 check passed
@abedell-phantom
abedell-phantom deleted the perf/quiet-reactivation branch October 8, 2026 00:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants