Skip to content

Conversation

@KyleAMathews
Copy link
Contributor

Add async parsing methods that yield to the main thread periodically, preventing the UI from becoming unresponsive when syncing shapes with many rows.

  • Add yieldToMain() utility that uses scheduler.yield() when available, falling back to setTimeout(0)
  • Add parseAsync() and parseSnapshotDataAsync() methods to MessageParser that yield every 1000 messages by default
  • Update ShapeStream to always use async parsing for long-poll responses and snapshots
  • Export yieldToMain and DEFAULT_YIELD_EVERY for users who need custom yielding in their own processing code

…hapes

Add async parsing methods that yield to the main thread periodically,
preventing the UI from becoming unresponsive when syncing shapes with
many rows.

- Add yieldToMain() utility that uses scheduler.yield() when available,
  falling back to setTimeout(0)
- Add parseAsync() and parseSnapshotDataAsync() methods to MessageParser
  that yield every 1000 messages by default
- Update ShapeStream to always use async parsing for long-poll responses
  and snapshots
- Export yieldToMain and DEFAULT_YIELD_EVERY for users who need custom
  yielding in their own processing code
@codecov
Copy link

codecov bot commented Dec 2, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 61.64%. Comparing base (0408955) to head (06b0482).
⚠️ Report is 3 commits behind head on main.

❗ There is a different number of reports uploaded between BASE (0408955) and HEAD (06b0482). Click for more details.

HEAD has 3 uploads less than BASE
Flag BASE (0408955) HEAD (06b0482)
unit-tests 6 5
typescript 4 3
packages/typescript-client 1 0
Additional details and impacted files
@@             Coverage Diff             @@
##             main    #3540       +/-   ##
===========================================
- Coverage   75.24%   61.64%   -13.61%     
===========================================
  Files          51       40       -11     
  Lines        2743     1559     -1184     
  Branches      404      106      -298     
===========================================
- Hits         2064      961     -1103     
+ Misses        677      597       -80     
+ Partials        2        1        -1     
Flag Coverage Δ
electric-telemetry 22.71% <ø> (-0.28%) ⬇️
elixir 57.38% <ø> (-0.09%) ⬇️
elixir-client 73.94% <ø> (ø)
packages/experimental 87.73% <ø> (ø)
packages/react-hooks 86.48% <ø> (ø)
packages/typescript-client ?
packages/y-electric 55.12% <ø> (ø)
typescript 72.39% <ø> (-15.06%) ⬇️
unit-tests 61.64% <ø> (-13.61%) ⬇️

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.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Dec 2, 2025

Open in StackBlitz

npm i https://pkg.pr.new/@electric-sql/react@3540
npm i https://pkg.pr.new/@electric-sql/client@3540
npm i https://pkg.pr.new/@electric-sql/y-electric@3540

commit: 06b0482

- Remove flaky timing assertion in yield test that assumed specific
  setTimeout ordering
- Run prettier on modified files
- Add yield test for parseAsync to improve coverage (was only testing
  parseSnapshotDataAsync yields)
- Fix globalThis guard order in yieldToMain - move typeof check before
  assignment to avoid reference errors in exotic environments
Remove the yieldEvery parameter from parseAsync and parseSnapshotDataAsync
methods - yield interval is now fixed at 1000 messages internally. This
simplifies the API; configurability can be added later if users need it.

- Remove yieldEvery parameter from async parsing methods
- Remove DEFAULT_YIELD_EVERY from public exports
- Update tests to work with fixed yield interval
Copy link
Contributor

@kevin-dp kevin-dp left a comment

Choose a reason for hiding this comment

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

The code looks good but i'm worried about the arbitrary yield every 1000 messages. Depending on the messages we may be yielding too often or not often enough. I think a time-based yielding strategy would be more appropriate. At 60 FPS we basically have 16.6ms per frame so we should yield every 10ms (maximum). Something like this:

const BUDGET = 8; // ms
let start = performance.now();

for (let i = 0; i < msgs.length; i++) {
  // do work

  if (performance.now() - start > BUDGET) {
    await yieldToMain()
    start = performance.now()
  }
}

this.transformParsedMessage(parsed[i], schema)

// Yield periodically to prevent blocking
if ((i + 1) % DEFAULT_YIELD_EVERY === 0) {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: i'd prefer if parseAsync and parseSnapshotDataAsync would take the yield every configuration as an argument instead of relying on a global. We can still have a global configuration but i would pass that global as an argument when calling these functions.

@KyleAMathews
Copy link
Contributor Author

It's just a setTimeout which returns essentially immediately unless something else needs a bunch of CPU — so essentially free to do whenever. I'm not sure the interval really matters?

@kevin-dp
Copy link
Contributor

It's just a setTimeout which returns essentially immediately unless something else needs a bunch of CPU — so essentially free to do whenever. I'm not sure the interval really matters?

I get what you mean about setTimeout returning quickly, but I still think the yield frequency is important.

Even if the yield itself is “cheap,” it isn’t free. Every time we yield we’re:

  1. Pausing the loop
  2. Scheduling a microtask/macrotask
  3. Letting the event loop swap us out and back in

That context-switching overhead adds up if we do it far more often than necessary. Yielding every 1000 messages might mean we yield thousands of times per second on fast hardware or large batches, which is unnecessary churn.

On the other hand, if each message is expensive, yielding every 1000 iterations might mean we block the main thread for much longer than intended—possibly tens of milliseconds—which risks jank.

The browser’s main thread time budget is fundamentally time-based, not iteration-based, so a time-based yielding strategy matches the actual constraint:

  • If messages are cheap, we’ll process more per slice.
  • If messages are expensive, we’ll yield sooner.
  • We stay within a predictable frame budget (~8–10ms), so we avoid blocking rendering.

The goal is not to yield “whenever,” but to yield “often enough to keep the app responsive, while not yielding so often that we waste time context switching.”

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.

4 participants