On priority and scheduling
Phaze’s scheduler is a single microtask queue. There’s no withPriority. There’s no getPriority. There’s no 'background' / 'user-visible' / 'user-blocking' distinction at all — every effect runs on the next microtask.
This is on purpose. Here’s the full story.
What “priority” means in a UI
Section titled “What “priority” means in a UI”When state changes, the framework runs effects to update the DOM. If you have a lot of effects scheduled at once — say, you just typed a character into a search box that filters 10,000 rows — those effects compete with browser work like:
- Painting the next frame
- Firing the next
keydownevent - Running the next animation tick
- Honoring user scroll input
If your effects monopolize the main thread, those don’t happen on time and the app feels janky. Priority-aware scheduling tries to let some effects run later, after the urgent stuff (input, paint) has its chance.
That’s the whole concept. “Priority” is just the answer to “when does this effect actually run?”
What browsers give you natively
Section titled “What browsers give you natively”Modern Chromium and Firefox expose a built-in scheduler with three priorities matching the same shape every framework copied:
// 'user-blocking' — runs ASAP, blocks the next frameawait scheduler.postTask(work, { priority: 'user-blocking' })
// 'user-visible' — DEFAULT. Yields to input, runs in the same task.await scheduler.postTask(work, { priority: 'user-visible' })
// 'background' — runs only when nothing else needs the main thread.await scheduler.postTask(work, { priority: 'background' })
// Cooperative yield — voluntarily release the thread.// Anything more urgent goes first; you resume after.await scheduler.yield()Plus the older but well-supported pair:
requestIdleCallback(work) // run in the next idle windowsetTimeout(work, 0) // next macrotask, after input/paintThese are the real primitives. Everything frameworks expose for “priority” is a wrapper around one of these.
When you actually need priority-aware scheduling
Section titled “When you actually need priority-aware scheduling”Four use cases that come up in practice. Read these and ask yourself whether your app does any of them.
1. Filtering or searching a large dataset
Section titled “1. Filtering or searching a large dataset”You have a search box. Each keystroke calls a filter over thousands of items. Without yielding, the next keystroke waits until the filter finishes — input feels laggy.
const query = signal('')const items = signal<Item[]>(loadedFromAPI)
const results = computed(() => items().filter(i => i.title.includes(query())) // can be slow)
// In the bench: 10ms filter × 5 keystrokes/sec = 50ms/s of blocked main thread.// User sees lag.2. Background data sync / prefetching
Section titled “2. Background data sync / prefetching”You want to warm a cache (load the next page of results, prefetch images, populate IndexedDB). It’s important but not now important. If it runs in the same priority as user input, it slows everything down.
3. Heavy derived computations
Section titled “3. Heavy derived computations”Same shape as #1: a computed that does meaningful CPU work. Phaze recomputes it lazily on read, but if the result is rendered to the DOM, the read happens during commit and you’ve just added 50ms to your update cycle.
4. Animation choreography
Section titled “4. Animation choreography”A page transition is playing. While it’s playing you want to pause nonessential work so the animation hits 60fps. After it ends, resume the work.
If your app has none of these, you have no priority problem. Most apps have none of these. A typical CRUD app — forms, lists under a few hundred items, click-to-update flows — never benefits from priority-aware scheduling.
How Phaze handles each case today
Section titled “How Phaze handles each case today”Case 1 (filtering): manual with setTimeout(0) / scheduler.yield
Section titled “Case 1 (filtering): manual with setTimeout(0) / scheduler.yield”Wrap the expensive work in a deferred path:
const query = signal('')const items = signal<Item[]>([])const results = signal<Item[]>([])
effect(() => { const q = query() // Yield to the next macrotask so the keystroke renders first. setTimeout(() => { results.set(items.current().filter(i => i.title.includes(q))) }, 0)})Or with the browser scheduler, when available:
effect(() => { const q = query() scheduler?.postTask(() => { results.set(items.current().filter(i => i.title.includes(q))) }, { priority: 'background' })})That’s one line of native primitive vs Phaze pretending to wrap it.
Case 2 (background sync): scheduler.postTask directly
Section titled “Case 2 (background sync): scheduler.postTask directly”effect(() => { const userId = currentUser() scheduler.postTask(() => prefetch(`/users/${userId}/details`), { priority: 'background', signal: abortSignal(), })})Phaze already gives you abortSignal() for free. Pair it with native postTask.
Case 3 (heavy computeds): same as #1, deferred via setTimeout or postTask
Section titled “Case 3 (heavy computeds): same as #1, deferred via setTimeout or postTask”computed() itself recomputes synchronously on read — that’s the contract. To defer the work, run it inside an effect that uses setTimeout or postTask, then write the result to a separate signal.
Case 4 (animations): keep references and pause manually
Section titled “Case 4 (animations): keep references and pause manually”Hold a signal<boolean> for “is animation playing” and gate effects on it:
const animating = signal(false)
effect(() => { if (animating()) return // pause this work during transitions doExpensiveThing(stuff())})Toggle animating from your transition onstart / onend handlers.
Why Phaze doesn’t ship a priority API in core
Section titled “Why Phaze doesn’t ship a priority API in core”Three reasons:
1. The default tax was real
Section titled “1. The default tax was real”The 3-priority scheduler we used to ship cost ~150 B brotli in every bundle. The runtime had:
- Three queues
HAS_POSTTASK/HAS_YIELDfeature detection- Priority routing in
requestFlush - Cooperative
await scheduler.yield()in the flush loop - A
priorityfield on every Computation
For users who never called withPriority, none of that ran. They paid the bytes regardless.
2. Almost no one called withPriority
Section titled “2. Almost no one called withPriority”When we audited the codebase before removing it, zero tests, zero examples, and zero docs samples used it. It was API surface without users.
3. The native primitives are good
Section titled “3. The native primitives are good”scheduler.postTask is cross-browser (Chrome 94+, Edge 94+, Firefox 129+). Safari ships in 17.4+ — same baseline as Phaze’s other modern-primitives requirements.
If you genuinely need priority routing, the platform already has it. Phaze wrapping it would mean re-implementing what the browser exposes, in JS, in your bundle.
What an opt-in phaze/scheduler would look like
Section titled “What an opt-in phaze/scheduler would look like”If a real demand emerges (concrete use cases, not “what if”), Phaze plans to ship priority-aware scheduling as an opt-in subpath:
import { signal, effect } from '@madenowhere/phaze'import { withPriority } from '@madenowhere/phaze/scheduler' // ← opt in
const heavyResult = signal<Result | null>(null)
effect(() => { const input = userInput() withPriority('background', () => { heavyResult.set(expensiveCompute(input)) })})Importing from phaze/scheduler would replace the default microtask-only flush with a priority-aware flush that uses scheduler.postTask when available. Apps that don’t import it pay nothing — same opt-in pattern as phaze/store, phaze/portal, phaze/catch.
This is on the roadmap but not built yet. The trigger for building it: someone has a concrete app that demonstrably benefits from it. Until then, native browser primitives plus a plain effect cover every use case in this page.
The teaching moment
Section titled “The teaching moment”Most “scheduling” / “priority” features in JS frameworks are wrappers around primitives the browser already exposes, sized to be useful for the framework’s specific architecture (e.g. React’s concurrent mode is the priority story carved into a particular reconciler shape). For a framework like Phaze whose update path doesn’t have a reconciler — effects just run when their signal fires — there’s no architectural reason to bake priority routing into the core.
There’s a strong reason not to: every byte you ship is paid by every user, including the 95% who’ll never call the API. Default to the floor; add the feature when there’s a demonstrated need.
That’s the whole pitch:
Don’t pay for what you don’t use. Don’t reinvent what the platform gives you.
When Phaze 1.0 ships phaze/scheduler, it’ll be because real apps asked for it.