Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Async Systems and Background Tasks

Some work shouldn’t block a frame: decoding a large file, a network fetch, a GPU→CPU buffer readback. BackgroundTasksPlugin::new(worker_count) registers a small worker pool (Res<BackgroundTasks>) for exactly this, with four ways to use it depending on what you need back:

I want…UseResult delivery
A blocking closure run off-thread, native onlyBackgroundTasks::spawn_blockingpoll the returned TaskHandle<T> yourself
A future (async/.await) run off-thread, web-compatibleBackgroundTasks::spawn_asyncpoll the returned TaskHandle<T> yourself
A whole system that’s fire-and-forget async, no result needed.detach()nothing — genuinely fire-and-forget
A future whose result should show up as an ordinary eventAsyncEventWriter<T>automatic — arrives on EventReader<T>

spawn_blocking is the odd one out and named for it: there’s no OS thread to block in a browser tab, so it’s native-only. Everything else in this table works identically on native and web.

The friendliest option: AsyncEventWriter<T>

For the common case — “run this in the background, deliver the result as an event once it’s done” — AsyncEventWriter<T> combines spawn_async with the event system from the previous chapter, so consuming the result is completely ordinary:

struct ReadbackDone(Vec<u8>);

app.add_async_event::<ReadbackDone>();

fn start_readback(events: AsyncEventWriter<ReadbackDone>, backend: Res<WGPUBackend>) {
    let future = backend.readback_buffer(&buf);
    events.spawn(async move { ReadbackDone(future.await) });
}

fn on_readback(mut reader: EventReader<ReadbackDone>) {
    for event in reader.iter() {
        // event.0 is the Vec<u8> read back from the GPU
    }
}

It sits next to EventWriter<T> in the same vocabulary — EventWriter::send enqueues an event now, AsyncEventWriter::spawn enqueues one once the future resolves. Register the type with app.add_async_event::<T>(), not add_event — using the wrong one produces a hint telling you exactly that.

Fire-and-forget systems: .detach()

A whole system can be async without any of the above, if you genuinely don’t need the result back:

fn save_screenshot(tasks: Res<BackgroundTasks>) -> impl Future<Output = ()> + Send + 'static {
    let tasks = tasks.clone();
    async move {
        // ... write to disk ...
    }
}

app.add_system(SystemStage::Update, save_screenshot.detach());

The system runs synchronously as usual — its SystemParams are fetched normally — but instead of doing the work directly, it returns a future, which the scheduler hands to spawn_async and moves on from immediately. A real async fn can’t be used directly here: its returned future borrows every parameter, so it’s never 'static on its own. Extract the owned pieces you need in the ordinary function body, then move only those into the async move block you return.

Fetching a file over HTTP

Same shape as the readback example above — wrap the fetch in a future, spawn it, read the result off an EventReader in a later system. Only the body of the future differs between native and web:

struct FileLoaded(Result<Vec<u8>, String>);

app.add_async_event::<FileLoaded>();

fn start_download(events: AsyncEventWriter<FileLoaded>) {
    events.spawn(async move {
        FileLoaded(fetch_url("https://example.com/data.bin").await)
    });
}

fn on_file_loaded(mut reader: EventReader<FileLoaded>) {
    for FileLoaded(result) in reader.iter() {
        match result {
            Ok(bytes) => { /* ... */ }
            Err(e) => tracing::error!("download failed: {e}"),
        }
    }
}

#[cfg(target_arch = "wasm32")]
async fn fetch_url(url: &str) -> Result<Vec<u8>, String> {
    use wasm_bindgen::JsCast;
    use wasm_bindgen_futures::JsFuture;
    let window = web_sys::window().unwrap();
    let resp: web_sys::Response = JsFuture::from(window.fetch_with_str(url))
        .await.map_err(|e| format!("{e:?}"))?.dyn_into().unwrap();
    let buf = JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?)
        .await.map_err(|e| format!("{e:?}"))?;
    Ok(js_sys::Uint8Array::new(&buf).to_vec())
}

#[cfg(not(target_arch = "wasm32"))]
async fn fetch_url(url: &str) -> Result<Vec<u8>, String> {
    reqwest::get(url).await.map_err(|e| e.to_string())?
        .bytes().await.map(|b| b.to_vec()).map_err(|e| e.to_string())
}

The #[cfg] split lives entirely inside fetch_url — everything above it (the event, the spawn call, the reader) is identical on both platforms.

Getting a JS event into the scheduler

Going the other direction — a browser event (a button click, a custom postMessage) reaching your systems — doesn’t go through BackgroundTasks at all, since there’s no future to await; the callback fires synchronously whenever the browser decides to call it. The pattern is a plain channel, filled by a wasm_bindgen closure registered on the DOM element, drained by an ordinary system into an EventWriter:

#[derive(Clone)]
struct ButtonClicks(crossbeam_channel::Sender<()>, crossbeam_channel::Receiver<()>);

struct ButtonClicked;
app.add_event::<ButtonClicked>();

#[cfg(target_arch = "wasm32")]
fn setup_button_listener(app: &mut App) {
    let (tx, rx) = crossbeam_channel::unbounded();
    app.add_resource(ButtonClicks(tx.clone(), rx));

    let button = web_sys::window().unwrap().document().unwrap()
        .get_element_by_id("my-button").unwrap();
    let closure = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
        let _ = tx.send(());
    }).into_js_value();
    button.add_event_listener_with_callback("click", closure.unchecked_ref()).unwrap();
}

fn drain_button_clicks(clicks: Res<ButtonClicks>, mut writer: EventWriter<ButtonClicked>) {
    while clicks.1.try_recv().is_ok() {
        writer.send(ButtonClicked);
    }
}

fn on_click(mut reader: EventReader<ButtonClicked>) {
    for _ in reader.iter() { /* ... */ }
}

This is the same shape pebble::wgpu::window::WinitWindow already uses internally for the browser’s resize event — a Closure capturing a Sender, registered once at startup, drained by a system every tick. Your gameplay code only ever sees EventReader<ButtonClicked>; nothing downstream needs to know the event originated from outside the ECS at all.

Web support at a glance

APINativeWeb (wasm32)
BackgroundTasks::spawn_blocking❌ (queues a job that never runs)
BackgroundTasks::spawn_async / .detach() / AsyncEventWriter<T>
WGPUBackend::readback_buffer

The rule of thumb: if it’s a future, it runs everywhere. If it’s a blocking closure, it’s native-only — there’s no thread to block on in a browser tab. Running on the Web covers the rest of what’s platform-specific once graphics enter the picture.