Skip to content

JavaScriptEventLoop: Add opt-in yieldInterval for host responsiveness - #821

Merged
krodak merged 2 commits into
swiftwasm:mainfrom
PassiveLogic:kr/event-loop-yield-interval
Oct 16, 2026
Merged

krodak merged 2 commits into
swiftwasm:mainfrom
PassiveLogic:kr/event-loop-yield-interval

Conversation

@krodak

@krodak krodak commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Overview

JobQueue.runAllJobs() runs every queued job in a single microtask, including jobs enqueued along the way. Long-running async work blocks rendering, input, timers, and messages until it completes, and a Web Worker can't respond to messages in the meantime. Task.yield() doesn't help because the yielded task goes back into the same drain. Yielding from app code with Task.sleep only works if every ready task does it, and their timers then expire together.

This PR adds an opt-in yieldInterval. When set, a drain stops once the interval has passed and the remaining jobs continue in a new JavaScript task. The default (nil) keeps the current behavior, so latency-sensitive apps are unaffected.

JavaScriptEventLoop.shared.yieldInterval = .milliseconds(16)

1. Drain loop. After each job, runAllJobs() checks the elapsed time. Once the interval has passed and jobs remain, it schedules a continuation and returns with isSpinning still set, so new jobs wait for the continuation instead of starting another drain. Jobs are never interrupted.

2. Continuation. setImmediate (Node.js, Bun), else MessageChannel (browsers, Workers, Deno), else setTimeout(0). MessageChannel avoids the 4 ms clamp on nested timers; with setTimeout, the workload below was about 15% slower.

3. Clock. ContinuousClock, or performance.now() in Embedded Swift. A clock read goes through the WASI shim and costs more than a small job, so the drain reads it every N jobs. N starts at 1 and doubles (up to 256) while less than a quarter of the interval has passed, so slow jobs are still checked after every job and long runs of small jobs pay for a read only now and then. N goes back to 1 at the start of every drain. Trade-off: if a drain switches from many small jobs to slow ones, up to N - 1 slow jobs can run before the next read, so the yield can come later than the interval.

Measurements

A Swift/Wasm app that writes a 255k-node graph to SQLite, Chrome, medians:

No yield Task.sleep before each batch yieldInterval = 16 ms
Page: longest time with no serviced message 11.8 s 3.7 s 0.6 s
Dedicated Worker: p95 delay of a page→Worker message 11.9 s 3.1 s 0.1 s
Total write time 22.1 s 23.0 s 22.3 s

The remaining 0.6 s is a single synchronous job in the app, which no scheduler can split.

Scheduling overhead, 1M chained Task.yield() hops, Node.js, release:

nil yieldInterval set
Clock read after every job ~135 ns/job ~820 ns/job
Clock read every N jobs (this PR) ~135 ns/job ~195 ns/job

Testing

  • New JavaScriptEventLoopYieldIntervalTests (XCTest on Node.js):
    • yieldInterval is nil by default;
    • without an interval, a JS timer scheduled before 20 busy tasks runs only after all of them;
    • with 5 ms, it runs before the last one, both when the interval is set during the drain and before a new drain;
    • equal-priority tasks keep their order across yields.
  • make unittest
  • Embedded: JavaScriptEventLoop builds with the Embedded SDK, and a small Embedded client that sets the interval runs in Node.js and lets a JS timer run between jobs (without the interval, it does not).
  • Benchmark: 1M chained Task.yield() hops, 3 interleaved runs per build, median of 5 rounds each.
  • ./Utilities/format.swift applied.

Docs: a doc comment on yieldInterval and one bullet in the JavaScript Interop Cheat Sheet.

@krodak krodak self-assigned this Oct 16, 2026
@kateinoigakukun

kateinoigakukun commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

TBH, I'm not sure if we should bring this to the global executor because:

  1. Checking the deadline requires calling ContinuousClock.now, which ends up calling performance.now() through WASI shim, but it's not very cheap to call every job.
  2. I assume we usually don't have a crazy number of application code that chains async jobs and exceeds the frame budget. If that assumption is true, then the global performance trade-off might be a little bigger than the maintenance cost to add yields in some localized application code places.

Having said that, the option is opt-in, so it's fine if this is a quick big win for you 👍

@krodak

krodak commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

@kateinoigakukun
On 1: yeah, I measured it. With yieldInterval set, chained Task.yield() hops go from ~135 ns to ~820 ns per job. Reading the clock less often brings it to ~195 ns (PassiveLogic#27), but that adds a heuristic to the hot loop, so it's worse to maintain I guess

On 2: agreed, our case is probably unusual: a big SQLite write in the browser, spread across concurrent tasks in shared storage code. I tried local yields first, but Task.yield() doesn't give the browser a turn, and Task.sleep in each task makes the timers fire together, so the page was still blocked for seconds. From what I tried executor was the only place where it worked, but if you think this would be incorrect approach in general, I'll keep looking for alternatives 😅

If you'd rather not carry this in upstream, I'm fine keeping it in our fork 🙏🏻

@kateinoigakukun

Copy link
Copy Markdown
Member

Interesting, thanks for the data point! Then I think it could be a useful valid option even for other applications 👍

@krodak

krodak commented Oct 16, 2026

Copy link
Copy Markdown
Member Author

@kateinoigakukun should I include changes from linked draft PR?

@kateinoigakukun

Copy link
Copy Markdown
Member

The heuristic looks simple enough so worth including it

@krodak
krodak enabled auto-merge October 16, 2026 18:57
@krodak
krodak merged commit dbbfdc3 into swiftwasm:main Oct 16, 2026
16 checks passed
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.

2 participants