# How the Dioxus Scheduler Coordinates Async Tasks and Effects: A Deep Dive into the VirtualDom Runtime

> Learn how the Dioxus scheduler organizes async tasks and effects for deterministic execution. Explore the VirtualDom runtime's effective coordination.

- Repository: [Dioxus Labs/dioxus](https://github.com/DioxusLabs/dioxus)
- Tags: deep-dive
- Published: 2026-07-23

---

**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`](https://github.com/DioxusLabs/dioxus/blob/main/packages/core/src/scheduler.rs). This method compares the highest priority dirty scope against the highest priority dirty task using `ScopeOrder` comparison.

```rust
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:

```rust
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`:

```rust
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.

```rust
#[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.

```rust
#[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).

```rust
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`](https://github.com/DioxusLabs/dioxus/blob/main/scheduler.rs)** – Implements `ScopeOrder`, `DirtyTasks`, and the `pop_work` algorithm that decides whether to rerun a scope, poll a task, or run an effect.
- **[`runtime.rs`](https://github.com/DioxusLabs/dioxus/blob/main/runtime.rs)** – Holds the global `Runtime` with `BTreeSet` queues for dirty scopes, pending effects, and dirty tasks; provides `queue_task`, `queue_effect`, and `finish_render` methods.
- **[`tasks.rs`](https://github.com/DioxusLabs/dioxus/blob/main/tasks.rs)** – Defines the `Task` struct, spawning logic, and `handle_task_wakeup` for executing futures within their owning scope context.
- **[`virtual_dom.rs`](https://github.com/DioxusLabs/dioxus/blob/main/virtual_dom.rs)** – Orchestrates the event loop through `wait_for_work` and `render_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::new` enter a `dirty_tasks` queue and only execute via `handle_task_wakeup` after the component tree is stable.
- **Effects run last**: `pending_effects` only processes after `finish_render` confirms no dirty scopes or tasks remain, preventing stale DOM reads.
- **Cooperative multitasking**: All scheduling happens through `SchedulerMsg` channels, with `Task::wake` and `mark_dirty` acting 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`](https://github.com/DioxusLabs/dioxus/blob/main/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`](https://github.com/DioxusLabs/dioxus/blob/main/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.