Skip to content
45 changes: 45 additions & 0 deletions .changeset/fast-localstorage-mutations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@tanstack/db": patch
---

Significantly improve localStorage collection performance during rapid mutations

Optimizes localStorage collections to eliminate redundant storage reads, providing dramatic performance improvements for use cases with rapid mutations (e.g., text input with live query rendering).

**Performance Improvements:**

- **67% reduction in localStorage I/O operations** - from 3 reads + 1 write per mutation down to just 1 write
- Eliminated 2 JSON parse operations per mutation
- Eliminated 1 full collection diff operation per mutation
- Leverages in-memory cache (`lastKnownData`) instead of reading from storage on every mutation

**What Changed:**

1. **Mutation handlers** now use in-memory cache instead of loading from storage before mutations
2. **Post-mutation sync** eliminated - no longer triggers redundant storage reads after local mutations
3. **Manual transactions** (`acceptMutations`) optimized to use in-memory cache

**Before:** Each mutation performed 3 I/O operations:

- `loadFromStorage()` - read + JSON parse
- Modify data
- `saveToStorage()` - JSON stringify + write
- `processStorageChanges()` - another read + parse + diff

**After:** Each mutation performs 1 I/O operation:

- Modify in-memory data ✨ No I/O!
- `saveToStorage()` - JSON stringify + write

**Safety:**

- Cross-tab synchronization still works correctly via storage event listeners
- All 50 tests pass including 8 new tests specifically for rapid mutations and edge cases
- 92.3% code coverage on local-storage.ts
- `lastKnownData` cache kept in sync with storage through initial load, mutations, and cross-tab events

This optimization is particularly impactful for applications with:

- Real-time text input with live query rendering
- Frequent mutations to localStorage-backed collections
- Multiple rapid sequential mutations
73 changes: 28 additions & 45 deletions packages/db/src/local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,16 +346,6 @@ export function localStorageCollectionOptions(
lastKnownData
)

/**
* Manual trigger function for local sync updates
* Forces a check for storage changes and updates the collection if needed
*/
const triggerLocalSync = () => {
if (sync.manualTrigger) {
sync.manualTrigger()
}
}

/**
* Save data to storage
* @param dataMap - Map of items with version tracking to save to storage
Expand Down Expand Up @@ -413,24 +403,24 @@ export function localStorageCollectionOptions(
}

// Always persist to storage
// Load current data from storage
const currentData = loadFromStorage<any>(config.storageKey, storage, parser)

// Use lastKnownData (in-memory cache) instead of reading from storage
// Add new items with version keys
params.transaction.mutations.forEach((mutation) => {
const key = config.getKey(mutation.modified)
// Use the engine's pre-computed key for consistency
const key = mutation.key
const storedItem: StoredItem<any> = {
versionKey: generateUuid(),
data: mutation.modified,
}
currentData.set(key, storedItem)
lastKnownData.set(key, storedItem)
})

// Save to storage
saveToStorage(currentData)
saveToStorage(lastKnownData)

// Manually trigger local sync since storage events don't fire for current tab
triggerLocalSync()
// Confirm mutations through sync interface (moves from optimistic to synced state)
// without reloading from storage
sync.confirmOperationsSync(params.transaction.mutations)

return handlerResult
}
Expand All @@ -448,24 +438,24 @@ export function localStorageCollectionOptions(
}

// Always persist to storage
// Load current data from storage
const currentData = loadFromStorage<any>(config.storageKey, storage, parser)

// Use lastKnownData (in-memory cache) instead of reading from storage
// Update items with new version keys
params.transaction.mutations.forEach((mutation) => {
const key = config.getKey(mutation.modified)
// Use the engine's pre-computed key for consistency
const key = mutation.key
const storedItem: StoredItem<any> = {
versionKey: generateUuid(),
data: mutation.modified,
}
currentData.set(key, storedItem)
lastKnownData.set(key, storedItem)
})

// Save to storage
saveToStorage(currentData)
saveToStorage(lastKnownData)

// Manually trigger local sync since storage events don't fire for current tab
triggerLocalSync()
// Confirm mutations through sync interface (moves from optimistic to synced state)
// without reloading from storage
sync.confirmOperationsSync(params.transaction.mutations)

return handlerResult
}
Expand All @@ -478,21 +468,20 @@ export function localStorageCollectionOptions(
}

// Always persist to storage
// Load current data from storage
const currentData = loadFromStorage<any>(config.storageKey, storage, parser)

// Use lastKnownData (in-memory cache) instead of reading from storage
// Remove items
params.transaction.mutations.forEach((mutation) => {
// For delete operations, mutation.original contains the full object
const key = config.getKey(mutation.original)
currentData.delete(key)
// Use the engine's pre-computed key for consistency
const key = mutation.key
lastKnownData.delete(key)
})

// Save to storage
saveToStorage(currentData)
saveToStorage(lastKnownData)

// Manually trigger local sync since storage events don't fire for current tab
triggerLocalSync()
// Confirm mutations through sync interface (moves from optimistic to synced state)
// without reloading from storage
sync.confirmOperationsSync(params.transaction.mutations)

return handlerResult
}
Expand Down Expand Up @@ -546,13 +535,7 @@ export function localStorageCollectionOptions(
}
}

// Load current data from storage
const currentData = loadFromStorage<Record<string, unknown>>(
config.storageKey,
storage,
parser
)

// Use lastKnownData (in-memory cache) instead of reading from storage
// Apply each mutation
for (const mutation of collectionMutations) {
// Use the engine's pre-computed key to avoid key derivation issues
Expand All @@ -565,18 +548,18 @@ export function localStorageCollectionOptions(
versionKey: generateUuid(),
data: mutation.modified,
}
currentData.set(key, storedItem)
lastKnownData.set(key, storedItem)
break
}
case `delete`: {
currentData.delete(key)
lastKnownData.delete(key)
break
}
}
}

// Save to storage
saveToStorage(currentData)
saveToStorage(lastKnownData)

// Confirm the mutations in the collection to move them from optimistic to synced state
// This writes them through the sync interface to make them "synced" instead of "optimistic"
Expand Down
Loading
Loading