Skip to content

perf: optimize hot paths in persistence, scheduling, and React hooks#1542

Open
KyleAMathews wants to merge 2 commits into
mainfrom
perf
Open

perf: optimize hot paths in persistence, scheduling, and React hooks#1542
KyleAMathews wants to merge 2 commits into
mainfrom
perf

Conversation

@KyleAMathews
Copy link
Copy Markdown
Collaborator

@KyleAMathews KyleAMathews commented May 20, 2026

Summary

Optimize hot paths across three packages to reduce unnecessary allocations and improve algorithmic complexity. The headline win is in useLiveQuery — status-only consumers (isReady, isLoading, status) now skip entry materialization entirely, dropping from ~2.3ms to ~0.001ms on 50k-row collections.

Root cause

Several hot paths used patterns with unnecessary overhead:

  • Sorting with inline JSON.stringifytoStableSerializable called JSON.stringify on both operands in every sort comparison, producing O(n log n) serializations instead of O(n)
  • Queue flush via while/shift — repeated shift() on a long array is O(n²) due to re-indexing
  • Nested findIndex loopsKeyScheduler.updateTransactions and loadPendingTransactions used O(n×m) lookups where O(n+m) sufficed
  • Eager entry materializationuseLiveQuery called Array.from(collection.entries()) on every snapshot, even when the consumer only read status flags

Approach

@tanstack/db-sqlite-persistence-core

  • Schwartzian transform for Set/Map sorting: serialize each element once (O(n)), sort on pre-computed keys, then strip decoration. Avoids redundant JSON.stringify in the comparator.
  • splice(0) queue flush replaces while/shift — detaches the queue atomically in O(n), then iterates. Items arriving during async processing accumulate in the original array for the next flush.

@tanstack/offline-transactions

  • Map-based lookup in KeyScheduler.updateTransactions — builds a Map from the update array, then single-pass updates. O(n+m) instead of O(n×m).
  • getEarliestRetryTime moved to KeyScheduler with a simple loop — avoids spreading into Math.min(...array) which could stack-overflow on large arrays.
  • Set-based filtering in loadPendingTransactions for removed transaction detection.
  • Hoisted Date.now() in resetRetryDelays — all transactions get the same timestamp.

@tanstack/react-db

  • Lazy property access in useLiveQuerystate and data are now getters that defer collection.entries() until first access. Status flags (isReady, isLoading, etc.) are plain properties that never trigger materialization.

Key invariants

  • Anti-tearing guarantee preserved: both state and data derive from a shared lazy getEntries() snapshot. If a consumer reads both, they see the same point-in-time view of the collection. The singleResult path also uses getEntries() for consistency, intentionally trading some getter-level speed for correctness.
  • Queue flush semantics: splice(0) detaches the queue before iterating. This is safe because isHydrating is false by the time flush runs, so no new items can be pushed during processing.
  • FIFO ordering: KeyScheduler.updateTransactions re-sorts by createdAt after bulk updates to maintain execution order.

Non-goals

  • Incremental cached updates — the real remaining React perf problem is that any collection change rebuilds a full snapshot when data or state is accessed. Fixing that requires incremental update propagation from change payloads, which is a much larger effort.
  • Further singleResult optimization — an earlier version used collection.values().next().value to avoid full materialization, but this could tear relative to state. Correctness was chosen over the extra speed.

Benchmarks

Local runs via pnpm bench:perf on Node v24.11.1 / macOS arm64. Numbers are medians. Baseline numbers are from the pre-change implementation where available; the queue-drain benchmark compares the old shift() strategy to the new cursor/splice-style strategy.

Area Benchmark Before After Change
React useLiveQuery status-only, 50k rows 2.309ms 0.001ms ~99.96% faster
React useLiveQuery data access, 50k rows 2.786ms 2.711ms ~2.7% faster / roughly neutral
React useLiveQuery singleResult, 50k rows 2.561ms 2.043ms ~20% faster
Offline transactions KeyScheduler.updateTransactions all pending, 1k 8.652ms 6.584ms ~24% faster
Offline transactions KeyScheduler.updateTransactions all pending, 5k 209.177ms 149.484ms ~29% faster
Offline transactions TransactionExecutor.loadPendingTransactions half filtered, 1k 5.926ms 1.627ms ~73% faster
Offline transactions TransactionExecutor.loadPendingTransactions half filtered, 5k 95.927ms 39.506ms ~59% faster
SQLite persistence / queue drain array queue drain, 10k (shift() → cursor/splice-style) 0.655ms 0.309ms ~53% faster

Additional after-change benchmark now covered by the harness:

Area Benchmark After Notes
Offline transactions KeyScheduler.getEarliestRetryTime with setup, 5k 150.209ms Added to guard the new direct scheduler scan and avoid Math.min(...array) argument-spread risk. No pre-change median was captured for this specific case.

Verification

pnpm --filter @tanstack/react-db test -- --run
pnpm --filter @tanstack/db-sqlite-persistence-core test -- --run
pnpm --filter @tanstack/offline-transactions test -- --run

Files changed

File Change
packages/react-db/src/useLiveQuery.ts Lazy getters for state/data with shared entries snapshot
packages/react-db/tests/useLiveQuery.test.tsx Tests for lazy materialization, singleResult, state getter
packages/db-sqlite-persistence-core/src/persisted.ts Schwartzian transform + splice-based queue flush
packages/db-sqlite-persistence-core/tests/persisted.test.ts Tests for stable serialization + concurrent queue behavior
packages/offline-transactions/src/executor/KeyScheduler.ts Map-based updateTransactions + getEarliestRetryTime
packages/offline-transactions/src/executor/TransactionExecutor.ts Delegate to scheduler, Set-based filtering, Date.now() hoist
packages/offline-transactions/tests/KeyScheduler.test.ts Tests for bulk update, FIFO ordering, earliest retry time
package.json Add bench:perf script

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Transaction scheduler now exposes earliest retry time computation for improved scheduling visibility.
  • Performance

    • Optimized hot paths in persistence layer via stable serialization sorting and efficient queue processing.
    • Enhanced live query performance through lazy property materialization—status reads no longer materialize unnecessary data entries.
  • Tests

    • Added coverage for edge cases in transaction queuing during persistence reloads.
    • Added performance-sensitive tests verifying lazy data access patterns in live queries.

Review Change Stack

KyleAMathews and others added 2 commits May 20, 2026 11:03
Reduce unnecessary allocations and algorithmic complexity:

- Schwartzian transform for Set/Map sorting in toStableSerializable (O(n) vs O(n log n) serializations)
- splice(0) queue flush instead of while/shift (O(n) vs O(n²))
- Map-based lookup in KeyScheduler.updateTransactions (O(n+m) vs O(n*m))
- Set-based filtering in loadPendingTransactions
- Lazy property access in useLiveQuery — status-only consumers skip entries() entirely
- Hoist Date.now() in resetRetryDelays for consistent timestamps

The useLiveQuery lazy getters share a single entries snapshot to prevent
tearing between state and data access.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 20, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1ae91634-25fa-4c63-9595-aaa45e3789da

📥 Commits

Reviewing files that changed from the base of the PR and between 4e9ab39 and 6c3326f.

📒 Files selected for processing (9)
  • .changeset/optimize-hot-paths.md
  • package.json
  • packages/db-sqlite-persistence-core/src/persisted.ts
  • packages/db-sqlite-persistence-core/tests/persisted.test.ts
  • packages/offline-transactions/src/executor/KeyScheduler.ts
  • packages/offline-transactions/src/executor/TransactionExecutor.ts
  • packages/offline-transactions/tests/KeyScheduler.test.ts
  • packages/react-db/src/useLiveQuery.ts
  • packages/react-db/tests/useLiveQuery.test.tsx

📝 Walkthrough

Walkthrough

This PR optimizes hot paths across three packages: stable deterministic ordering and splice-based queue draining in SQLite persistence; Map-based bulk transaction updates and Set-based filtering in offline-transactions; and lazy entry materialization in useLiveQuery to avoid full collection traversal when only status is accessed.

Changes

Hot-Path Performance Optimizations

Layer / File(s) Summary
Stable Serialization & Queue Optimizations
packages/db-sqlite-persistence-core/src/persisted.ts
toStableSerializable now uses Schwartzian transform: precomputes JSON representation of each Set/Map entry, sorts by that serialized form, then returns original entries in deterministic order. Hydration and committed-transaction queue flushing switches from repeated shift() in a loop to snapshot-and-clear via splice(0), then iteration.
Persistence Core Integration Tests
packages/db-sqlite-persistence-core/tests/persisted.test.ts
Tests verify that tx:committed messages queued during reload flush are applied; and that cursor options with Map/Set values are deduped even when insertion order differs, confirming stable serialization deduplication.
Transaction Scheduler Enhancements
packages/offline-transactions/src/executor/KeyScheduler.ts
Adds getEarliestRetryTime() to return minimum nextAttemptAt (or null). Refactors updateTransactions() to build id→transaction map and apply bulk updates in single pass with early-return for empty input, then re-sort by createdAt to preserve FIFO.
Transaction Executor Optimization
packages/offline-transactions/src/executor/TransactionExecutor.ts
Filters removed transactions via Set of filteredTransactions IDs for O(1) lookup instead of array check; delegates getEarliestRetryTime() entirely to scheduler; caches Date.now() constant in resetRetryDelays for consistent application across all pending transactions.
Transaction Scheduler Tests
packages/offline-transactions/tests/KeyScheduler.test.ts
Tests bulk updateTransactions() correctness: multiple pending updates, unknown ID filtering, preservation of unchanged entries, deduplication on duplicate IDs, FIFO maintenance, and getEarliestRetryTime() edge cases (empty/populated).
Lazy Entry Materialization in useLiveQuery
packages/react-db/src/useLiveQuery.ts
Refactors returned state and data getters to defer full entry materialization: shared getEntries() caches collection entries on first access; data branches into singleResultCache (findOne) or dataCache (full); status flags (isReady, isError) read from local collection instead of snapshot to avoid forcing entry materialization.
useLiveQuery Performance Tests
packages/react-db/tests/useLiveQuery.test.tsx
Adds performance-sensitive test suite verifying status-only access does not materialize entries; data/state reads materialize lazily without forcing values() iteration; findOne mode extracts value from shared entries; single-result query returns undefined on empty collection.
Performance Measurement & Release
package.json, .changeset/optimize-hot-paths.md
Adds bench:perf npm script for performance benchmarking; documents patch releases with summary of all hot-path optimizations.

🎯 3 (Moderate) | ⏱️ ~25 minutes


🐰 Hopping through hot paths with care,
Stable sorts and queues laid bare,
Lazy loads and sets that hiss,
Performance gains you can't miss,
Every optimization's a love affair! 💨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: optimizing hot paths across three packages (persistence, scheduling, React hooks) with specific performance improvements.
Description check ✅ Passed The description comprehensively covers all template sections with detailed rationale, implementation approach, benchmarks, and verification steps. It exceeds the template's basic requirements.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 20, 2026

More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1542

@tanstack/browser-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/browser-db-sqlite-persistence@1542

@tanstack/capacitor-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/capacitor-db-sqlite-persistence@1542

@tanstack/cloudflare-durable-objects-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/cloudflare-durable-objects-db-sqlite-persistence@1542

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1542

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1542

@tanstack/db-sqlite-persistence-core

npm i https://pkg.pr.new/@tanstack/db-sqlite-persistence-core@1542

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1542

@tanstack/electron-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/electron-db-sqlite-persistence@1542

@tanstack/expo-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/expo-db-sqlite-persistence@1542

@tanstack/node-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/node-db-sqlite-persistence@1542

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1542

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1542

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1542

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1542

@tanstack/react-native-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/react-native-db-sqlite-persistence@1542

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1542

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1542

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1542

@tanstack/tauri-db-sqlite-persistence

npm i https://pkg.pr.new/@tanstack/tauri-db-sqlite-persistence@1542

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1542

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1542

commit: 6c3326f

@github-actions
Copy link
Copy Markdown
Contributor

Size Change: 0 B

Total Size: 117 kB

ℹ️ View Unchanged
Filename Size
packages/db/dist/esm/collection/change-events.js 1.39 kB
packages/db/dist/esm/collection/changes.js 1.38 kB
packages/db/dist/esm/collection/cleanup-queue.js 810 B
packages/db/dist/esm/collection/events.js 434 B
packages/db/dist/esm/collection/index.js 3.61 kB
packages/db/dist/esm/collection/indexes.js 1.99 kB
packages/db/dist/esm/collection/lifecycle.js 1.69 kB
packages/db/dist/esm/collection/mutations.js 2.47 kB
packages/db/dist/esm/collection/state.js 5.26 kB
packages/db/dist/esm/collection/subscription.js 3.74 kB
packages/db/dist/esm/collection/sync.js 2.88 kB
packages/db/dist/esm/collection/transaction-metadata.js 144 B
packages/db/dist/esm/deferred.js 207 B
packages/db/dist/esm/errors.js 4.92 kB
packages/db/dist/esm/event-emitter.js 748 B
packages/db/dist/esm/index.js 3.01 kB
packages/db/dist/esm/indexes/auto-index.js 830 B
packages/db/dist/esm/indexes/base-index.js 729 B
packages/db/dist/esm/indexes/basic-index.js 2.05 kB
packages/db/dist/esm/indexes/btree-index.js 2.17 kB
packages/db/dist/esm/indexes/index-registry.js 820 B
packages/db/dist/esm/indexes/reverse-index.js 538 B
packages/db/dist/esm/local-only.js 890 B
packages/db/dist/esm/local-storage.js 2.1 kB
packages/db/dist/esm/optimistic-action.js 359 B
packages/db/dist/esm/paced-mutations.js 496 B
packages/db/dist/esm/proxy.js 3.75 kB
packages/db/dist/esm/query/builder/functions.js 1.37 kB
packages/db/dist/esm/query/builder/index.js 5.54 kB
packages/db/dist/esm/query/builder/ref-proxy.js 1.22 kB
packages/db/dist/esm/query/compiler/evaluators.js 1.83 kB
packages/db/dist/esm/query/compiler/expressions.js 430 B
packages/db/dist/esm/query/compiler/group-by.js 3.54 kB
packages/db/dist/esm/query/compiler/index.js 4.75 kB
packages/db/dist/esm/query/compiler/joins.js 2.34 kB
packages/db/dist/esm/query/compiler/order-by.js 1.72 kB
packages/db/dist/esm/query/compiler/select.js 1.4 kB
packages/db/dist/esm/query/effect.js 4.78 kB
packages/db/dist/esm/query/expression-helpers.js 1.43 kB
packages/db/dist/esm/query/ir.js 971 B
packages/db/dist/esm/query/live-query-collection.js 360 B
packages/db/dist/esm/query/live/collection-config-builder.js 8.01 kB
packages/db/dist/esm/query/live/collection-registry.js 264 B
packages/db/dist/esm/query/live/collection-subscriber.js 1.95 kB
packages/db/dist/esm/query/live/internal.js 145 B
packages/db/dist/esm/query/live/utils.js 1.75 kB
packages/db/dist/esm/query/optimizer.js 2.62 kB
packages/db/dist/esm/query/predicate-utils.js 2.97 kB
packages/db/dist/esm/query/query-once.js 359 B
packages/db/dist/esm/query/subset-dedupe.js 960 B
packages/db/dist/esm/scheduler.js 1.3 kB
packages/db/dist/esm/SortedMap.js 1.3 kB
packages/db/dist/esm/strategies/debounceStrategy.js 247 B
packages/db/dist/esm/strategies/queueStrategy.js 428 B
packages/db/dist/esm/strategies/throttleStrategy.js 246 B
packages/db/dist/esm/transactions.js 3.02 kB
packages/db/dist/esm/utils.js 927 B
packages/db/dist/esm/utils/array-utils.js 273 B
packages/db/dist/esm/utils/browser-polyfills.js 304 B
packages/db/dist/esm/utils/btree.js 5.61 kB
packages/db/dist/esm/utils/comparison.js 1.05 kB
packages/db/dist/esm/utils/cursor.js 457 B
packages/db/dist/esm/utils/index-optimization.js 1.54 kB
packages/db/dist/esm/utils/type-guards.js 157 B
packages/db/dist/esm/virtual-props.js 360 B

compressed-size-action::db-package-size

@github-actions
Copy link
Copy Markdown
Contributor

Size Change: 0 B

Total Size: 4.24 kB

ℹ️ View Unchanged
Filename Size
packages/react-db/dist/esm/index.js 249 B
packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.32 kB
packages/react-db/dist/esm/useLiveQuery.js 1.34 kB
packages/react-db/dist/esm/useLiveQueryEffect.js 355 B
packages/react-db/dist/esm/useLiveSuspenseQuery.js 567 B
packages/react-db/dist/esm/usePacedMutations.js 401 B

compressed-size-action::react-db-package-size

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.

1 participant