Skip to content

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.

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 keydown event
  • 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?”

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 frame
await 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 window
setTimeout(work, 0) // next macrotask, after input/paint

These 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.

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.

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.

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.

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.

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:

The 3-priority scheduler we used to ship cost ~150 B brotli in every bundle. The runtime had:

  • Three queues
  • HAS_POSTTASK / HAS_YIELD feature detection
  • Priority routing in requestFlush
  • Cooperative await scheduler.yield() in the flush loop
  • A priority field on every Computation

For users who never called withPriority, none of that ran. They paid the bytes regardless.

When we audited the codebase before removing it, zero tests, zero examples, and zero docs samples used it. It was API surface without users.

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.

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.