# 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:
| A blocking closure run off-thread, native only | `BackgroundTasks::spawn_blocking` | poll the returned `TaskHandle<T>` yourself |
| A future (`async`/`.await`) run off-thread, web-compatible | `BackgroundTasks::spawn_async` | poll 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 event | `AsyncEventWriter<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:
```rust
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:
```rust
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 `SystemParam`s 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:
```rust
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`:
```rust
#[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
| `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](./ch12-web.md) covers the rest of what's platform-specific once graphics enter the picture.