Skip to content

feat: Add client-side cache buster for expired shapes to prevent 409s - #3103

Merged
KyleAMathews merged 16 commits into
mainfrom
409-optimization
Sep 9, 2025
Merged

KyleAMathews merged 16 commits into
mainfrom
409-optimization

Conversation

@KyleAMathews

Copy link
Copy Markdown
Contributor

Summary

  • Implements client-side caching of 409'd shapes using localStorage
  • Adds cache_buster parameter to prevent redundant 409 responses
  • Reduces app loading latency when shapes expire

Changes

  • Add SHAPE_CACHE_BUSTER_QUERY_PARAM constant for expired shape requests
  • Store expired shape handles in localStorage when 409 responses occur
  • Check for expired shapes during URL construction and add cache buster
  • Gracefully handle localStorage availability and errors

Test plan

  • Test 409 response handling stores shape in localStorage
  • Verify subsequent requests include cache_buster parameter
  • Confirm behavior works correctly when localStorage unavailable
  • Test multiple expired shapes don't interfere with each other

Fixes #3100

🤖 Generated with Claude Code

When a shape 409s, the client now stores this information in localStorage
and adds a cache_buster parameter to future requests for that shape,
preventing redundant 409 responses and reducing app loading latency.

- Add localStorage helpers for tracking expired shapes per shape handle
- Add SHAPE_CACHE_BUSTER_QUERY_PARAM for expired shape requests
- Store expired shape handles when 409 responses occur
- Add cache buster to URL construction for known expired shapes

Fixes #3100

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 6, 2025 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.87234% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 77.57%. Comparing base (1c55d2a) to head (1d3b493).
⚠️ Report is 11 commits behind head on main.

Files with missing lines Patch % Lines
...ages/typescript-client/src/expired-shapes-cache.ts 97.14% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3103      +/-   ##
==========================================
+ Coverage   77.40%   77.57%   +0.17%     
==========================================
  Files         169      170       +1     
  Lines        9007     9076      +69     
  Branches      282      293      +11     
==========================================
+ Hits         6972     7041      +69     
  Misses       2033     2033              
  Partials        2        2              
Flag Coverage Δ
elixir 76.31% <ø> (+0.06%) ⬆️
elixir-client 73.87% <ø> (-0.54%) ⬇️
packages/experimental 87.73% <ø> (ø)
packages/react-hooks 86.48% <ø> (+0.18%) ⬆️
packages/typescript-client 93.49% <97.87%> (+0.33%) ⬆️
packages/y-electric 55.12% <ø> (ø)
postgres-150000 76.43% <ø> (+0.22%) ⬆️
postgres-170000 76.40% <ø> (-0.01%) ⬇️
sync-service 76.57% <ø> (+0.12%) ⬆️
typescript 85.79% <97.87%> (+0.56%) ⬆️
unit-tests 77.57% <97.87%> (+0.17%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@kevin-dp kevin-dp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, left a few very minor comments.

#markShapeExpired(shapeHandle: string): void {
if (typeof localStorage === `undefined`) return
try {
localStorage.setItem(this.#getShapeStorageKey(shapeHandle), `true`)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we can remove the if test if we do localStorage?.setItem(...)

export const CHUNK_UP_TO_DATE_HEADER = `electric-up-to-date`
export const COLUMNS_QUERY_PARAM = `columns`
export const LIVE_CACHE_BUSTER_QUERY_PARAM = `cursor`
export const SHAPE_CACHE_BUSTER_QUERY_PARAM = `cache_buster`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to introduce a separate one? Could we not use the cursor also for this one? Since up till now we only use cursor for cache busting live requests, it seems that we can reuse it to bust non-live shape requests too? Just needs a renaming from LIVE_CACHE_BUSTER_QUERY_PARAM to CACHE_BUSTER_QUERY_PARAM.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The LLM thought the same thing 😆

I suppose in theory we could because this new cache busting is only relevant when we're not live and reading cached values & the live cache cursor is needed for preventing CDNs from returning cached live requests 🤔

But they are different. This PR introduces an actual normal cache buster where the live stuff is a cursor — so cache busting but different flavor as it's helping ensure we move forward through log time.

@samwillis

Copy link
Copy Markdown
Contributor

I'm slightly nervous of accumulating these expired shape handles in local storage as individual keys, while it's true we can probably hold 10s k in it, it seems a little messy.

Could we:

  • constrain the list to the last 100
  • store them in a single key in local storage
  • load all into memory on start
  • when we add a new one push out the oldest (when > 100) and serialise + save
  • watch for the storage change event from other tabs to load the set when it changes:
window.addEventListener("storage", (event) => {
  if (event.key === "electric_expired_shape_handles") {
    // reload expired handles
  }
});

@KyleAMathews

Copy link
Copy Markdown
Contributor Author

@samwillis hmm yeah a little LRU cache would be needed for very large apps w/ lots of churn. Easy enough to add...

@kevin-dp

kevin-dp commented Sep 8, 2025 •

Copy link
Copy Markdown
Contributor

I'm slightly nervous of accumulating these expired shape handles in local storage as individual keys, while it's true we can probably hold 10s k in it, it seems a little messy.

Keeping track of a set of expired shape handles seems like the perfect use case for a bloom filter. It will tell us whether the shape is possibly expired or definitely not expired. If it tells us it is possibly expired, we add a cache buster (no real harm if it would be a false positive).

Might be overkill for apps with "few" shapes as we can easily store several thousand shape handles in local storage but might be worth it for apps with huge amounts of shapes.

@KyleAMathews

Copy link
Copy Markdown
Contributor Author

perfect use case for a bloom filter

Yeah with a ton more shapes that could make sense — I'm more worried though about all the extra code for a bloom filter. I'm thinking of capping the number of shapes at 250 as that's a lot more than we support right now anyways. That'll be cheap still to load (10-15kb) and iterate over.

@netlify

netlify Bot commented Sep 8, 2025

Copy link
Copy Markdown

✅ Deploy Preview for electric-next ready!

Name Link
🔨 Latest commit a55d3c7
🔍 Latest deploy log https://app.netlify.com/projects/electric-next/deploys/68beffcea549e700080fd8fe
😎 Deploy Preview https://deploy-preview-3103--electric-next.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@KyleAMathews

Copy link
Copy Markdown
Contributor Author

This is ready for another review.

@samwillis samwillis left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we are close, and maybe there, but wanted to clarify some thinking.

You are saving one expired handle per url, but as we have seen there can be multiple that cause weird loops. What was your thinking here?

In my mind, as handles are uuids we don't need to bucket them by url, just have a LRU cache of the last n of them. I would keep this as a list with a max length serialised to storage, and as a set in memory for fast lookup.

It may well be that per url is ok, in which case this is good to go I think.

Once thing I think thats worth following up on in a further PR is standardising a key-value metadata interface that ShapeStream takes as a config option. This can default to using a thin wrapper around localStorage in the browser, but provides a route to other storages for React Native / Capacitor / Electron / Node.

* LRU cache for tracking expired shapes with automatic cleanup
*/
export class ExpiredShapesCache {
private data: Record<string, ExpiredShapeCacheEntry> = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are only keeping the last expired handle per url? Is that enough?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the idea is that a given shape definition's browser cache might no longer be valid — so the cache key is by shape definition and we only need to store the last-invalidated handle to get out of that handle's cache. We only load one shape / shape definition so no need to store multiple handles or worry about additional URLs from other timelines of the shape that we might have cached in the past.

KyleAMathews and others added 4 commits September 9, 2025 09:04
…roxy support

The canonicalShapeKey function now excludes only Electric protocol parameters
(live, handle, offset, cursor, expired_handle) while including all other
parameters including custom ones used by proxies. This ensures proper cache
behavior when proxies use client-provided metadata to construct shape definitions.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
@KyleAMathews
KyleAMathews merged commit 64dcfec into main Sep 9, 2025
42 of 43 checks passed
@KyleAMathews
KyleAMathews deleted the 409-optimization branch September 9, 2025 22:58
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.

Feature request: Client stores if a shape 409s so then it can add a cacheBuster on future requests

3 participants