Sending Events from Rust
Learn how to emit events from Tauri's Rust backend to your React frontend using the event system, including global events, webview-specific events, and structured payloads
A Tauri command returns a value once and the call is over. That works for request–response patterns like “fetch this file” or “log in,” but it falls apart when the backend needs to push multiple updates to the frontend over time — a file download progressing in chunks, a long‑running scan discovering files, or a notification triggered by an external signal.
The Tauri event system fills that gap. From Rust, you can emit an event at any time, and the frontend can listen for it. This page covers the Rust side of that pipeline: how to emit events from your backend code, structure payloads, target specific windows, and avoid the most common pitfalls.
How the Event System Works
Every Tauri event is a name and an optional payload. The name is just a string — download-progress, file‑discovered, notification — and the payload is any serializable Rust type. Under the hood, Tauri serializes the payload to JSON, sends it across the WebView IPC bridge, and delivers it to all matching listeners on the frontend.
The key Rust type is AppHandle. It represents a handle to the running application and implements the Emitter trait, which provides the functions emit, emit_to, and emit_filter. If you have an AppHandle, you can send an event.
JSON serialization means size matters:
Because every payload is serialized to JSON, the event system is not suited for large binary data or high‑frequency streams (hundreds of events per second). For those cases, Tauri provides Channels, which send data as raw bytes. Events are right for things like progress updates, status changes, and user‑facing notifications.
Getting an AppHandle
The most natural place to emit an event is inside a Tauri command. When Tauri invokes a command, it can inject an AppHandle automatically if you add it as a parameter.
#[tauri::command]
fn start_download(app: tauri::AppHandle, url: String) {
// You now have an AppHandle to emit events with.
}
For code that runs outside a command — a background thread, a timer, or a callback from an external library — you need to hold onto an AppHandle obtained earlier. A common pattern stores it in a OnceCell during the app setup:
use tauri::Manager;
use std::sync::OnceLock;
static APP_HANDLE: OnceLock<tauri::AppHandle> = OnceLock::new();
fn main() {
tauri::Builder::default()
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
if let tauri::RunEvent::Ready = event {
APP_HANDLE.set(app_handle.clone()).ok();
}
});
}
Any other Rust module can then retrieve the handle with APP_HANDLE.get() and call emit on it. This technique is useful when an event source lives entirely in Rust and is not triggered by a frontend action.
Emitting Global Events
A global event reaches every frontend listener that registered for that event name, regardless of which webview window it lives in. Use the emit method on an AppHandle.
use tauri::{AppHandle, Emitter};
#[tauri::command]
fn notify_all(app: AppHandle, message: String) {
app.emit("global-notification", &message).unwrap();
}
The event name is "global-notification" and the payload is a simple String. On the frontend, any window that called listen('global-notification', ...) will receive it.
Unwrap can panic:
The emit function returns a Result. In production code, avoid unwrap() and handle the error — for instance, if the WebView has been closed and can no longer receive events. The examples here use unwrap for brevity.
Emitting to a Specific Webview
When you have multiple windows and only one of them should receive the event, use emit_to instead. It takes a window label and an event name.
use tauri::{AppHandle, Emitter};
#[tauri::command]
fn login(app: AppHandle, user: String, password: String) {
let authenticated = user == "admin" && password == "secret";
let result = if authenticated { "loggedIn" } else { "invalidCredentials" };
app.emit_to("login", "login-result", result).unwrap();
}
The first argument "login" is the label of the target window (set in tauri.conf.json or when creating the window). Only listeners registered in that window will receive login-result.
For cases where you need to send an event to a filtered set of windows, emit_filter lets you provide a closure that decides, for each possible target, whether to deliver the event.
use tauri::{AppHandle, Emitter, EventTarget};
#[tauri::command]
fn open_file(app: AppHandle, path: std::path::PathBuf) {
app.emit_filter("open-file", path, |target| match target {
EventTarget::WebviewWindow { label } => {
label == "main" || label == "file-viewer"
}
_ => false,
}).unwrap();
}
Sending Structured Data
Passing a string is fine for simple signals, but most real events carry multiple fields. Define a struct that derives Serialize and Clone, then pass an instance as the payload.
use tauri::{AppHandle, Emitter};
use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DownloadProgress {
download_id: usize,
bytes_downloaded: u64,
total_bytes: u64,
}
#[tauri::command]
fn download(app: AppHandle, url: String) {
for chunk in [1024, 2048, 3072, 4096] {
app.emit("download-progress", DownloadProgress {
download_id: 1,
bytes_downloaded: chunk,
total_bytes: 4096,
}).unwrap();
}
}
The #[serde(rename_all = "camelCase")] attribute keeps your Rust fields snake_case while the frontend receives camelCase keys (bytesDownloaded), matching JavaScript naming conventions.
The payload type must be Clone and Serialize:
Tauri requires Clone because it may need to send the event to multiple listeners, and Serialize to convert it to JSON. Forgetting either derive will cause a compile error. The error message from Emitter is usually clear about which trait is missing.
Complete Example: Simulating a Download with Progress Updates
This example ties together everything shown so far. A Rust command simulates a file download, emitting start, progress, and finish events. A React component listens and displays the progress bar.
Rust Backend
use tauri::{AppHandle, Emitter};
use serde::Serialize;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DownloadStarted<'a> {
url: &'a str,
total_bytes: u64,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DownloadProgress {
bytes_downloaded: u64,
total_bytes: u64,
}
#[tauri::command]
fn simulate_download(app: AppHandle, url: String) {
let total = 5000;
app.emit("download-started", DownloadStarted {
url: &url,
total_bytes: total,
}).unwrap();
for bytes in [1000, 2500, 4000, 5000] {
std::thread::sleep(std::time::Duration::from_millis(800));
app.emit("download-progress", DownloadProgress {
bytes_downloaded: bytes,
total_bytes: total,
}).unwrap();
}
app.emit("download-finished", &url).unwrap();
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![simulate_download])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
The command is blocking; std::thread::sleep freezes the current thread, which is acceptable for a demonstration because each step is quick. In a real application you would use an async command and a non‑blocking sleep, or spawn a background task.
React Frontend
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
type DownloadStartedPayload = {
url: string;
totalBytes: number;
};
type DownloadProgressPayload = {
bytesDownloaded: number;
totalBytes: number;
};
function App() {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState("idle");
useEffect(() => {
const unlistenProgress = listen<DownloadProgressPayload>(
"download-progress",
(event) => {
setProgress(
Math.round((event.payload.bytesDownloaded / event.payload.totalBytes) * 100)
);
}
);
const unlistenStart = listen<DownloadStartedPayload>(
"download-started",
(event) => {
setStatus(`Downloading ${event.payload.url}`);
}
);
const unlistenFinish = listen<string>("download-finished", () => {
setStatus("Download complete");
setProgress(100);
});
return () => {
unlistenProgress.then((fn) => fn());
unlistenStart.then((fn) => fn());
unlistenFinish.then((fn) => fn());
};
}, []);
return (
<main>
<h1>Download Simulator</h1>
<button onClick={() => invoke("simulate_download", { url: "https://example.com/file.zip" })}>
Start Download
</button>
<p>{status}</p>
<progress value={progress} max={100} />
</main>
);
}
export default App;
The component registers three listeners when it mounts and cleans them up when it unmounts. Clicking the button invokes the Rust command, which emits the events in sequence, and the UI updates in real time.
Everything is working if you see the progress bar move:
After clicking the button, the progress bar should fill up in steps, and the status text should change from “Downloading …” to “Download complete”. If the UI freezes instead, verify that the event names match exactly between Rust ("download-progress") and the frontend listener — a typo causes silence with no error.
The frontend listening code here is intentionally minimal. The next section, Listening for Events in React, covers cleanup patterns, the unlisten timing pitfall, and how to handle events that arrive before the listener is set up.
Common Mistakes When Emitting Events
A silent failure when emitting an event is usually one of the following:
- Forgetting to register the command. If the Rust function is never added to
generate_handler![],invokewill fail before any event is emitted. The browser console will show an error likecommand not found. - Mismatched event name strings. Rust uses
"download-started"but the frontend listens for"downloadStarted". Tauri does not transform event names — the string must match exactly. - Sending large payloads repeatedly. A loop emitting a struct with a
Vec<u8>field containing a megabyte of data will serialize a megabyte of JSON every few milliseconds, choking the IPC bridge. Use Channels for anything heavier than a few kilobytes. - Calling
emitafter the window is closed. If the user closes the window while a background thread is still emitting,emitwill return an error. Ignoring this error withunwrapcan panic the Rust process. For background tasks, check ifAppHandleis still valid before emitting.
When to Use Events Instead of Commands
Commands are a request–response model: the frontend asks, the backend answers. Events are a push model: the backend decides when to send data, and the frontend just listens. Use events when:
- A single operation produces multiple updates (download progress, file scanning).
- The trigger originates outside the frontend (a system tray action, a timer, a socket message).
- Multiple frontend windows need to be notified of the same state change.
If you need a typed request with a guaranteed response, a command is the better fit. If you are pushing data, events are the right tool.
Summary
The event system turns a one‑shot Rust command into a continuous stream of updates. By holding an AppHandle and calling emit, emit_to, or emit_filter, you can push structured data to one window, a filtered set, or every window in the application. The next section covers the frontend side in depth: how to listen, clean up, and avoid ordering traps with React's lifecycle.