Skip to content

Spike the use-async-scheduler optional feature from RFC 957 - #21554

Draft
NullVoxPopuli-ai-agent wants to merge 2 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:use-async-scheduler-flag
Draft

NullVoxPopuli-ai-agent wants to merge 2 commits into
emberjs:mainfrom
NullVoxPopuli-ai-agent:use-async-scheduler-flag

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Spike for the intermediary migration step discussed in the RFC 957 review meeting: one optional feature flag that moves Ember's own scheduling (rendering, runloop callbacks, RSVP flush) onto @ember/scheduler, while backburner, RSVP, and the full @ember/runloop API keep working exactly as today for everyone who has not opted in. Nothing is dropped or deprecated first. Apps can absorb the timing change while they still run addons that use the runloop, because the runloop API itself is re-implemented on the scheduler.

Targets main. The flag needs @ember/scheduler, so the first commit is #21552's interface commit, rebased onto main (f3ce59665d). The second commit is the flag (6ff35df2fe).

The flag

EmberENV._USE_ASYNC_SCHEDULER, following the default-async-observers precedent (the app-facing use-async-scheduler name would come from @ember/optional-features once this is real). Read lazily at every decision point, so tests can flip it per-module.

Flag off (default)

No behavior change: every gated call site falls through to the exact code that exists today. Full suite after the rebase onto main: 9539 tests, 9521 passed, 18 skipped, 0 failed.

Flag on

Runloop, re-implemented on the scheduler (@ember/runloop/-private/scheduler-loop.ts), per the RFC's transition table:

runloop API becomes
run / join / bind execute the callback directly (same onerror routing)
schedule('render') scheduler render() phase
schedule('afterRender') scheduler layout() phase
schedule(anything else) Promise.resolve().then(...)
scheduleOnce / once same mapping, deduplicated by queue/target/method, latest args win
next scheduler next() phase
later / debounce / throttle / cancel setTimeout-backed timers with the same coalescing semantics
begin / end no-op

Backburner is not used at all on this path. If the app has not registered a strategy, the default FrameStrategy is registered on first use.

Rendering goes through the scheduler's render phase instead of backburner's render queue: scheduleRevalidate (both the global-context hook and the renderer's own) schedules one deduplicated revalidation pass before the next paint. The reflush loop (_RERENDER_LOOP_LIMIT) and renderSettled() semantics carry over. The render phase resolves in-window during its own flush, so reflushes stay within the frame.

RSVP resolves on the microtask queue like a native promise instead of joining a runloop; unhandled-rejection re-throw happens on a timeout instead of the private error queue. Ember error dispatch (Ember.onerror / dispatch override) is unchanged.

Tests

packages/@ember/runloop/tests/use_async_scheduler_test.js runs the runloop surface and renderSettled() with the flag enabled (14 tests): sync run/join/bind, microtask schedule('actions'), phase ordering (actions → render → afterRender), scheduleOnce/once dedupe, cancellation, later/debounce/throttle timing, and renderSettled resolving with no backburner runloop.

Known gaps (why this is a spike)

  • settled()/test waiters: _hasScheduledTimers() reports the scheduler-backed timers, and the backend tracks all pending work (_hasPendingWork()), but @ember/test-helpers still watches backburner directly. The RFC's "test waiters observe the scheduler" story is the critical follow-up before the flag is adoptable.
  • Queue ordering: classic runloop guarantees actions → routerTransitions → render → afterRender → destroy per loop. Microtask-mapped queues (actions, routerTransitions, destroy, custom queues) now run FIFO in scheduling order rather than queue order.
  • run() no longer forces a synchronous render flush. There is no runloop to flush. Interop relying on "DOM is updated when run() returns" needs await renderSettled().
  • Async observers flush at the start of the render pass (and on any schedule they request), not at runloop end.

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

rere-benchmark numbers at 4x CPU throttle: main (f1c718c4), this PR with the flag off and on (c67c99c, flag flipped via globalThis.EmberENV = { _USE_ASYNC_SCHEDULER: true } in the app's index.html), and the all-optimizations spike #21520 (c09efdc). Medians of 5 interleaved runs per variant (3 for DB Monitor), headless Chrome, same machine, prod builds.

ms, lower is better (DB Monitor is fps, higher is better):

bench main #21554 flag off #21554 flag on #21520 spike
DB Monitor (fps) 47.0 49.4 57.6 59.4
Incrementing Render Effect 3190.3 3080.0 2723.5 3147.4
1 item, 1k updates (async) 51.2 52.9 12.1 11.8
1 item, 1k updates 7.2 9.1 15.0 10.2
1 item, 100k updates (async) 1313.1 1282.0 88.6 52.2
1 item, 100k updates 35.1 31.6 64.6 26.8
1k items, 1 update each (seq, async) 976.3 1019.0 55.2 48.5
1k items, 1 update each (seq) 42.1 45.0 51.6 47.7
1k items, 5% random (async) 93.3 95.1 33.1 26.4
1k items, 25% random (async) 303.8 317.2 39.9 31.6

Reading:

  • Flag off tracks main on every bench (deltas within run noise, both directions) — the gated call sites are perf-neutral when disabled.
  • Flag on lands most of the spike's async win: 10–25x on the async benches (RSVP/microtask resolution no longer pays a runloop per await) and +10 fps on DB Monitor. The remaining gap to SPIKE: every rendering-performance lever combined (RFC 957 end state + VM optimizations) #21520 is the spike's VM optimizations, not scheduling.
  • Known cost: the small synchronous benches pay for rAF-scheduled rendering (7.2→15.0, 35.1→64.6, 42.1→51.6 ms) — same shape we measured on the spike before its VM work. This is the race-flush/strategy-tuning follow-up.
  • Caveat: headless caps rAF at 60Hz, so the 57.6/59.4 fps readings are near ceiling; the fps gap is understated.

@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Same setup as above, at 8x CPU throttle (medians of 5 interleaved runs, 3 for DB Monitor):

ms, lower is better (DB Monitor is fps, higher is better):

bench main #21554 flag off #21554 flag on #21520 spike
DB Monitor (fps) 6.8 6.8 19.1 23.6
Incrementing Render Effect 6200.1 6236.5 5372.2 6172.6
1 item, 1k updates (async) 98.5 101.4 29.5 22.3
1 item, 1k updates 13.9 14.7 24.6 16.9
1 item, 100k updates (async) 2670.7 2638.6 171.3 108.2
1 item, 100k updates 59.3 64.0 117.2 51.1
1k items, 1 update each (seq, async) 1918.7 2054.8 101.2 97.7
1k items, 1 update each (seq) 88.8 89.7 102.9 90.4
1k items, 5% random (async) 204.9 213.8 57.0 49.5
1k items, 25% random (async) 644.0 598.2 67.0 63.1

8x is out of the headless 60Hz rAF ceiling, so DB Monitor now discriminates: flag on nearly triples fps over main (6.8 → 19.1), with the spike's VM work adding the rest (23.6). Everything else mirrors the 4x picture — flag off ≈ main, flag on takes the async wins (10–20x), small sync benches pay the rAF-flush latency (13.9 → 24.6, 59.3 → 117.2).

@kategengler

Copy link
Copy Markdown
Member

Converting to draft since it is a spike

NullVoxPopuli and others added 2 commits October 3, 2026 02:57
Adds the `@ember/scheduler` package proposed by RFC 0957:

- `render`, `layout`, `composite`, `next` and `idle` phase functions,
  each returning a promise that resolves according to the registered
  scheduling strategy
- `registerStrategy`, for providing the scheduling strategy when defining
  the Application
- `@ember/scheduler/strategy`, the default strategy implementation, which
  flushes the render/layout/composite phases in order via ordered
  requestAnimationFrame callbacks within a single frame, prior to paint

The deprecations of @ember/runloop and RSVP described by the RFC are left
to follow-up work; this is the additive API surface.

Fix Safari flake and idle() starvation in the default strategy

The "composite while composite is flushing" test asserted ordering across
two independent channels: a setTimeout task scheduled during frame 1
versus frame 2's requestAnimationFrame callbacks. The HTML spec does not
order pending timer tasks against the next rendering opportunity, and
Safari 15.6 runs the next frame's rAF callbacks first. The test now
anchors entirely to the rAF channel, using a raw requestAnimationFrame
registered ahead of the rescheduled phase windows as the frame-2
boundary.

idle() also armed requestIdleCallback without a timeout; fully-idle or
backgrounded pages can starve rIC indefinitely, leaving the promise
unresolvable. Cap the wait with { timeout: 500 }.
Adds EmberENV._USE_ASYNC_SCHEDULER (the app-wide optional feature from
RFC 0957's migration roadmap, following the default-async-observers
precedent) as the intermediary migration step: when enabled, Ember's own
scheduling moves onto @ember/scheduler while backburner, RSVP, and the
full @ember/runloop API keep working unchanged for everyone who has not
opted in.

With the flag enabled:

- @ember/runloop is re-implemented on the scheduler: run/join/bind
  execute their callback directly, schedule('render'/'afterRender') map
  onto the render and layout phases, every other queue becomes a
  microtask, next maps onto the next phase, and later/debounce/throttle
  become setTimeout-backed timers with the same coalescing semantics.
  Backburner is not used at all.
- Rendering is scheduled into the scheduler's render phase (one
  deduplicated revalidation pass before the next paint) instead of
  backburner's render queue; the reflush loop and renderSettled()
  semantics carry over.
- RSVP resolves on the microtask queue like a native promise instead of
  joining a runloop.

With the flag disabled (the default) every gated call site falls through
to the exact code that exists today; the full suite is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli-ai-agent
NullVoxPopuli-ai-agent changed the base branch from nvp/scheduler-interface to main October 3, 2026 15:07
@NullVoxPopuli-ai-agent

Copy link
Copy Markdown
Contributor Author

Rebased onto main (f693f24). The branch had fallen 406 commits behind.

This PR now targets main instead of nvp/scheduler-interface, because that branch is still 406 commits behind, and against it the diff showed 473 files. The first commit here is #21552's interface commit, rebased (f3ce596). The second is this PR's flag (6ff35df).

Local checks on the rebased branch: tsc --noEmit, the type tests, pnpm build, ESLint, and Prettier pass. The browser suite passes: 9539 tests, 9521 passed, 18 skipped, 0 failed.

🤖 Generated with Claude Code

This branch has not been deployed

No deployments
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.

3 participants