Async tasks and background work
Use day::task to await work without blocking the interface. Its futures run on Day’s main
loop, so they can read and write signals before and after an .await. Day provides this
executor itself; apps do not need Tokio to use it.
Use a worker thread for CPU-heavy or blocking work. Return its results to the UI through
Setter or on_main, because signals cannot move between threads.
For app examples, see HTTP requests and Local storage. This reference covers task lifetimes, cancellation, and the rules for implementing asynchronous parts.
day::task and TaskHandle
button("Save").action(move || {
day::task(async move {
if confirm("Overwrite?").await { // native modal (docs/dialogs.md)
let resp = day_part_http::fetch_future(req).await;
status.set(render(resp)); // UI thread — a plain signal write
}
});
});
task(fut) polls the future once before returning and hands back a TaskHandle (Copy,
!Send, freely discardable). handle.abort() removes and drops the task’s future; dropping
the future invokes its cleanup. For fetch_future, that requests cancellation where the
platform supports it; other implementations finish in the background and discard the result.
See the HTTP cancellation contract. Aborting a finished task is a no-op; is_finished() reports
completed-or-aborted. Task ids are never reused, so stale handles are harmless.
A task is not owned by the scope that starts it. Leaving a page does not abort its tasks.
Keep the handle and abort it explicitly, or use Resource for work that should end with a scope.
Resource and Load (day::reactive)
Resource is the declarative layer: a tracked source whose value feeds an async fetcher,
and the result is stored in a Signal<Load<T>>.
use day::reactive::{Load, Resource};
let stations = Resource::new(
move || region.get(), // tracked — refetch on change
|region| async move { fetch_stations(region).await }, // Result<T, E: Error + Send + Sync>
);
when(move || stations.ready(), move || station_list(stations));
stations.refetch(); // force, even if region is unchanged
Load<T>isLoading | Ready(T) | Failed(Arc<dyn Error + Send + Sync>),Clone, withready()/is_loading()/is_ready()/error()accessors.Resourceis aCopyhandle:signal(),get(),with(),loading(),ready()(all tracked),refetch().- Latest wins. A source change supersedes the in-flight fetch: its task is aborted (the
drop requests cancellation where supported) and a completion that slips through writes
nothing.
refetch()always fetches; a rerun with an unchanged source value fetches nothing. - Scope cleanup cancels the fetch. When the owning scope is disposed, its task is aborted. A late write to the disposed signal has no effect.
- The fetcher runs on the main-loop executor, so it may read and write signals after its
awaits, and its source value needs no
Sendbound. §4.5’sMaybeSendbound was removed for this reason. See the DESIGN status note. - Namespacing: the prelude’s
Resourceis the asset handle (docs/resources.md), which predates this type; the async one lives atday::reactive::Resource, or depend onday-reactivedirectly.
day-part-http pairs with it for the common case (see the showcase’s Platform-services page:
the loopback Resource demo, the PATCH fetch_future demo, and the URL checker that aborts
its previous in-flight task on re-tap).
The policy
These rules keep UI state on the main thread and let parts work without depending on a particular executor:
- Piece builders and event handlers are synchronous. No
async fninPiece::build, actions, or event handlers.day::task(async { … })is the one explicit bridge from a sync action into a sequential flow. day::taskis the only executor for signal-touching futures. Its futures run on the UI thread, so after an.awaitthey read and write signals directly, without aSetteror marshaling. Futures that never touch signals may run anywhere.- Parts expose a callback and a future, never a runtime-bound API.
fetch_async(req, cb)plusfetch_future(req); both must work in a plain-mainbinary and undercargo test(so a part never callson_mainitself, per docs/http.md’s contract). - Keep other async runtimes in app-specific crates. A dependency that demands tokio
(matrix-rust-sdk) gets a headless core crate owning that runtime on background threads;
results cross back only through
Setter/on_main, and!Sendhandles never leave the main thread. The Day-Matrix app’smatrix-corecrate (a standalone Day app) is the reference; its bridge rule is documented at the top of its lib.rs. Noday-*crate depends on an async runtime. - Return to the UI thread through
Setteroron_main(DESIGN §3.3). Completion callbacks that run on background threads (e.g.fetch_async) deliver through them; futures onday::taskdon’t need them.
Under the hood
- The executor (
crates/day-core/src/present.rs) stores boxed futures in a thread-local map; waking posts a re-poll throughday_reactive::on_main. It is std-only, ~100 lines. - day-reactive reaches the executor through an installed hook (
install_spawner, the poster/scheduler pattern) because day-core depends on day-reactive, not the reverse.day_core::launch_withwires it on every backend (including mock). The spawner returns an abort closure that must be a no-op after completion: the spawner polls eagerly, so a synchronously-ready fetcher finishes beforeResourcecan store the abort. FetchFuture(docs/http.md) is oneshot plumbing overfetch_async’s completion callback; itsDropruns the platform cancel. It has no executor dependency; any executor can await it, including a test’sblock_on.day-async(2026-09) holds that plumbing once, std-only:oneshot()— aDeliver<T>any thread may send from and aOneshot<T>any executor can await, resolving toDroppedrather than pending forever when the sender goes away — andTokenRegistry<T>, the register-before-call, remove-by-token map behind every platform completion that crosses an FFI boundary as a number. The bridge’s callback tier (docs/bridge.md “Callbacks”) is its first user; the parts’ hand-rolled copies migrate onto it.day-asyncalso owns the process’s one timer thread:schedule(delay, job)runs a job after a delay andunschedule(id)forgets it, so a part’s time limits and backoff wait there instead of parking a thread each. day-part-http’s total-time limits and question timeouts, and day-part-downloads’ retry backoff, use it. The web has no threads, so a timer never fires there.
Test hooks
- day-core executor tests: install an inline poster once
(
day_reactive::install_main_poster(|f| f())) and every wake re-polls synchronously on the test thread (present.rs’stask_tests). - day-reactive Resource tests:
install_spawnera miniature executor (poll-once at spawn + an explicitpump()), and resolve hand-rolled manual futures (resource_tests). - day-part-http future tests: a ~25-line park/unpark
block_on(tests/http.rs). The completion’s wake from the delegate queue is exactly the cross-thread path production uses. - Missing installs fail loudly:
on_mainand the spawner panic with “backend not started” rather than dropping work.