How the Dioxus Scheduler Coordinates Async Tasks and Effects: A Deep Dive into the VirtualDom Runtime
Dioxus runs a single-threaded cooperative scheduler that guarantees deterministic execution order by processing dirty component scopes first, polling async tasks second, and running effects only after the DOM is fully updated.
Dioxus is a React-like UI framework for Rust that centralizes all reactive work inside a VirtualDom with an integrated scheduler. This scheduler manages three distinct categories of work—component re-renders, asynchronous futures, and side effects—using a priority system that prevents race conditions and ensures DOM consistency.
The Three-Tier Execution Model
The scheduler categorizes all pending work into three mutually exclusive queues with strict priority ordering. When the event loop processes work, it always selects the highest priority item available.
Dirty Scopes: Highest Priority
When a component's state changes, VirtualDom::mark_dirty calls Runtime::queue_scope to add the component's ScopeId to the dirty_scopes set. These scopes are stored in a BTreeSet<ScopeOrder> sorted by height (distance from the root), ensuring parent components always render before their children. The scheduler processes every dirty scope before touching any async tasks or effects, preventing a child from executing after it has been dropped by a parent update.
Async Tasks: Middle Priority
Futures spawned via Task::new or Runtime::spawn enter the dirty_tasks queue when they wake. When a task's wake method is called, it sends a SchedulerMsg::TaskNotified through the runtime's channel, which triggers VirtualDom::mark_task_dirty to insert the task into a BTreeSet<DirtyTasks>. The scheduler polls these tasks only after all dirty scopes have been resolved, ensuring async code never observes stale component state.
Effects: Lowest Priority
Effects created by use_effect or use_effect_once are queued via Runtime::queue_effect into the pending_effects collection. These closures run only when both the dirty_scopes and dirty_tasks sets are empty, guaranteeing that effect code executes against the fully updated DOM.
Scheduler Architecture and Message Passing
The scheduler lives inside the VirtualDom and communicates through a channel of SchedulerMsg messages. The Runtime struct maintains the three work queues and exposes methods to enqueue work from anywhere in the application.
When any work is queued, the runtime sends a message through runtime.sender to wake the scheduler. The main event loop—implemented in VirtualDom::wait_for_work or invoked via render_immediate—repeatedly calls process_events to drain this channel and populate the internal queues.
The Work Selection Algorithm
The core scheduling logic resides in VirtualDom::pop_work inside packages/core/src/scheduler.rs. This method compares the highest priority dirty scope against the highest priority dirty task using ScopeOrder comparison.
pub(crate) fn pop_work(&mut self) -> Option<Work> {
let dirty_scope = self.dirty_scopes.first();
let dirty_task = { /* find highest dirty task */ };
match (dirty_scope, dirty_task) {
(Some(scope), Some(task)) => {
match scope.cmp(&task.borrow()) {
Ordering::Less => Some(Work::RerunScope(self.dirty_scopes.pop_first().unwrap())),
_ => Some(Work::PollTask(self.pop_task().unwrap())),
}
}
// …remaining cases omitted for brevity
}
}
If the scope's height is less than the task's height (meaning the scope is closer to the root), the scheduler returns Work::RerunScope. Otherwise, it returns Work::PollTask. When only one type of work remains, it is returned directly. This ordering ensures that component updates always take precedence over async work, and async work always completes before effects fire.
Async Task Coordination
Async tasks integrate with the scheduler through a cooperative wakeup mechanism. When a task needs to poll, its wake method sends a message to the scheduler rather than executing immediately:
pub fn wake(&self) {
Runtime::with(|rt| {
let _ = rt.sender.unbounded_send(SchedulerMsg::TaskNotified(self.id));
})
}
Upon receiving TaskNotified, the scheduler calls VirtualDom::mark_task_dirty, which inserts the task into the dirty_tasks set using its associated ScopeOrder. When the scheduler later selects this task via pop_work, it invokes Runtime::handle_task_wakeup to execute the future inside the correct scope context using with_scope_on_stack. If the future completes, the runtime removes it from the task list; if it yields, it remains queued until the next wakeup.
Effect Timing and DOM Guarantees
Effects are queued via Runtime::queue_effect, which stores closures in a pending_effects map keyed by ScopeOrder:
pub(crate) fn queue_effect(&self, id: ScopeId, f: impl FnOnce() + 'static) {
let effect = Box::new(f);
let scope = self.get_state(id);
let mut effects = self.pending_effects.borrow_mut();
let height = scope.height();
let scope_order = ScopeOrder::new(height, id);
match effects.get(&scope_order) {
Some(e) => e.push_back(effect),
None => { effects.insert(Effect::new(scope_order, effect)); }
}
}
After render_immediate finishes processing all dirty scopes and tasks, it calls Runtime::finish_render, which sends SchedulerMsg::EffectQueued to trigger effect processing. The scheduler then drains pending_effects via Runtime::pop_effect. This sequencing guarantees that effects like DOM measurements or focus management always operate on the final, committed DOM state.
Practical Implementation Examples
Spawning an Async Task in a Component
This example demonstrates how Task::new registers a future with the runtime. The task wakes itself periodically, and the scheduler ensures it only polls after the component scope is clean.
#[component]
fn Counter() -> Element {
let count = use_signal(|| 0);
// Spawn a background task that increments the counter every second
use_effect(move || {
dioxus::prelude::Task::new(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
count += 1; // marks the scope dirty
}
});
});
rsx! { div { "Count: {count}" } }
}
The Task::new call creates a Task handle that registers with the runtime. When the timer completes and the task wakes, SchedulerMsg::TaskNotified ensures the scheduler polls it after any pending component updates.
Using use_effect for Safe DOM Manipulation
Effects run only after the DOM reflects the latest state, making them safe for imperative DOM operations.
#[component]
fn FocusInput() -> Element {
let input_ref = use_node_ref();
// Effect runs after the component has rendered, guaranteeing the input exists in the DOM
use_effect(move || {
if let Some(node) = input_ref.get() {
// Safe DOM manipulation – the DOM is fully updated
web_sys::HtmlElement::from(node).focus().unwrap();
}
});
rsx! {
input { ref: input_ref }
}
}
use_effect pushes its closure into Runtime::pending_effects. Because the scheduler only executes these after all dirty scopes and tasks resolve, the input_ref is guaranteed to point to a mounted DOM element.
Manual Task Waking
You can manually trigger a task poll by calling wake, which bypasses the normal async timing to schedule immediate execution (subject to priority rules).
let task = dioxus::prelude::Task::new(async { /* … */ });
task.wake(); // Sends SchedulerMsg::TaskNotified → scheduler will poll it
Key Source Files
The scheduler implementation spans four core files in the packages/core/src directory:
scheduler.rs– ImplementsScopeOrder,DirtyTasks, and thepop_workalgorithm that decides whether to rerun a scope, poll a task, or run an effect.runtime.rs– Holds the globalRuntimewithBTreeSetqueues for dirty scopes, pending effects, and dirty tasks; providesqueue_task,queue_effect, andfinish_rendermethods.tasks.rs– Defines theTaskstruct, spawning logic, andhandle_task_wakeupfor executing futures within their owning scope context.virtual_dom.rs– Orchestrates the event loop throughwait_for_workandrender_immediate, coordinating the scheduler's three queues to produce consistent UI updates.
Summary
- Dirty scopes always win: The scheduler uses
BTreeSet<ScopeOrder>to ensure components render from root to leaf before any async work begins. - Tasks poll after components: Async futures spawned via
Task::newenter adirty_tasksqueue and only execute viahandle_task_wakeupafter the component tree is stable. - Effects run last:
pending_effectsonly processes afterfinish_renderconfirms no dirty scopes or tasks remain, preventing stale DOM reads. - Cooperative multitasking: All scheduling happens through
SchedulerMsgchannels, withTask::wakeandmark_dirtyacting as non-blocking signals to the event loop.
Frequently Asked Questions
When exactly do effects run in Dioxus?
Effects run after the scheduler has completely drained both the dirty scopes queue and the dirty tasks queue. According to the VirtualDom implementation in virtual_dom.rs, effects only execute once render_immediate has processed all component updates and async tasks, and Runtime::finish_render has signaled completion. This ensures the DOM matches the component state when effect closures execute.
Can async tasks interrupt component rendering?
No. The pop_work method in scheduler.rs explicitly checks dirty_scopes.first() before considering any task. If any component is marked dirty, the scheduler returns Work::RerunScope and defers task polling until the component tree is fully updated. This prevents race conditions where async code might read obsolete props or state.
How does Dioxus prevent memory leaks in spawned tasks?
Tasks are associated with their creating scope's ScopeId and stored with a ScopeOrder in the dirty_tasks set. If a component unmounts, its scope is removed from the runtime, and any future wakeups for that scope's tasks are ignored during process_events. Additionally, when handle_task_wakeup runs, it uses with_scope_on_stack to verify the scope still exists before polling the future.
What happens if a task wakes while its component is dirty?
The task is added to the dirty_tasks set via mark_task_dirty, but it will not poll until pop_work determines that no higher-priority scopes remain. If the owning component is in dirty_scopes, the scheduler will rerun the component first, potentially updating the task's captured state. Once the scope is clean, the task polls against the fresh state, ensuring consistency.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →