diff --git a/Cargo.lock b/Cargo.lock
index 33dae79564..9970e0db29 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1108,6 +1108,20 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "external_communication"
+version = "0.0.0"
+dependencies = [
+ "console_error_panic_hook",
+ "console_log",
+ "futures-channel",
+ "futures-util",
+ "gloo-timers",
+ "log",
+ "wasm-bindgen-futures",
+ "xilem_web",
+]
+
[[package]]
name = "fastrand"
version = "2.3.0"
diff --git a/Cargo.toml b/Cargo.toml
index a5bbd5ad30..09752a52c7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,6 +13,7 @@ members = [
"xilem_web/web_examples/counter",
"xilem_web/web_examples/counter_custom_element",
"xilem_web/web_examples/elm",
+ "xilem_web/web_examples/external_communication",
"xilem_web/web_examples/fetch",
"xilem_web/web_examples/todomvc",
"xilem_web/web_examples/mathml_svg",
diff --git a/xilem_web/web_examples/external_communication/Cargo.toml b/xilem_web/web_examples/external_communication/Cargo.toml
new file mode 100644
index 0000000000..a84920f354
--- /dev/null
+++ b/xilem_web/web_examples/external_communication/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "external_communication"
+license.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+
+[[bin]]
+name = "external_communication"
+test = false
+doctest = false
+doc = false
+bench = false
+
+[lints]
+workspace = true
+
+[dependencies]
+console_error_panic_hook = "0.1.7"
+console_log = { version = "1.0.0", features = ["color"] }
+futures-channel = "0.3.31"
+futures-util = "0.3.31"
+gloo-timers = { version = "0.3.0", features = ["futures"] }
+log = "0.4.29"
+wasm-bindgen-futures = "0.4.58"
+xilem_web = { path = "../.." }
diff --git a/xilem_web/web_examples/external_communication/index.html b/xilem_web/web_examples/external_communication/index.html
new file mode 100644
index 0000000000..8d67dcedc9
--- /dev/null
+++ b/xilem_web/web_examples/external_communication/index.html
@@ -0,0 +1,7 @@
+
+
+
+ External communication example | Xilem Web
+
+
+
diff --git a/xilem_web/web_examples/external_communication/src/main.rs b/xilem_web/web_examples/external_communication/src/main.rs
new file mode 100644
index 0000000000..df05eb364c
--- /dev/null
+++ b/xilem_web/web_examples/external_communication/src/main.rs
@@ -0,0 +1,192 @@
+// Copyright 2026 the Xilem Authors
+// SPDX-License-Identifier: Apache-2.0
+
+//! This example shows how to communicate with external systems
+//! via messages that can change the app state.
+
+use std::fmt;
+
+use futures_channel::mpsc;
+use futures_util::{
+ FutureExt, StreamExt,
+ future::{self, Either},
+};
+use gloo_timers::future::TimeoutFuture;
+use wasm_bindgen_futures::spawn_local;
+use xilem_web::{
+ App,
+ concurrent::{ShutdownSignal, TaskProxy, task_raw},
+ core::{Edit, fork, one_of},
+ document_body,
+ elements::html,
+ interfaces::Element,
+};
+
+const CHANNEL_SIZE: usize = 5;
+
+enum ExternalMessage {
+ HelloFromExtern,
+}
+
+enum XilemMessage {
+ HelloFromXilem,
+ StartReceiving {
+ msg_tx: mpsc::Sender,
+ },
+}
+
+// We assume that the external message does not have `Debug` implemented,
+// so we need a wrapper because `TaskProxy::send_message` requires
+// the message to implement `Debug`.
+struct MessageWrapper(ExternalMessage);
+
+impl fmt::Debug for MessageWrapper {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "MessageWrapper(..)")
+ }
+}
+
+struct AppState {
+ msg_tx: mpsc::Sender,
+ message_count: usize,
+ run: bool,
+}
+
+impl AppState {
+ fn new() -> (Self, mpsc::Receiver) {
+ let (msg_tx, msg_rx) = mpsc::channel::(CHANNEL_SIZE);
+ (
+ Self {
+ msg_tx,
+ message_count: 0,
+ run: false,
+ },
+ msg_rx,
+ )
+ }
+}
+
+async fn create_receiver_task(
+ proxy: TaskProxy,
+ shutdown_signal: ShutdownSignal,
+ mut msg_tx: mpsc::Sender,
+) {
+ log::debug!("Xilem: start message receiver task");
+ let mut abort = shutdown_signal.into_future().fuse();
+ let (external_msg_tx, mut msg_rx) = mpsc::channel::(CHANNEL_SIZE);
+ let msg = XilemMessage::StartReceiving {
+ msg_tx: external_msg_tx,
+ };
+ if msg_tx.try_send(msg).is_err() {
+ log::info!("Xilem: No external receiver anymore; stop receiver task");
+ return;
+ };
+ log::debug!("Xilem: start receiving messages");
+ loop {
+ match future::select(msg_rx.next(), &mut abort).await {
+ Either::Left((Some(msg), _)) => {
+ proxy.send_message(MessageWrapper(msg));
+ continue;
+ }
+ Either::Left((None, _)) => {
+ // There is no sender anymore.
+ break;
+ }
+ Either::Right(_) => {
+ // The view no longer exists so
+ // we can do e.g. a graceful shutdown here.
+ break;
+ }
+ }
+ }
+ log::debug!("Xilem: stop message receiver task");
+}
+
+fn handle_message(state: &mut AppState, MessageWrapper(msg): MessageWrapper) {
+ match msg {
+ ExternalMessage::HelloFromExtern => {
+ log::info!("Xilem: Hello from extern");
+ state.message_count += 1;
+ }
+ }
+}
+
+fn app_logic(state: &mut AppState) -> impl Element> + use<> {
+ let tx = state.msg_tx.clone();
+ let task = task_raw::<_, _, _, Edit, _, _>(
+ move |proxy, shutdown_signal| create_receiver_task(proxy, shutdown_signal, tx.clone()),
+ handle_message,
+ );
+
+ html::div((
+ format!("Message count: {}", state.message_count),
+ if state.run {
+ one_of::Either::A(fork(
+ html::button("stop receiving").on_click(|state: &mut AppState, _| {
+ state.run = false;
+ }),
+ task,
+ ))
+ } else {
+ one_of::Either::B(html::button("start receiving").on_click(
+ |state: &mut AppState, _| {
+ state.run = true;
+ },
+ ))
+ },
+ html::button("Hello to Extern").on_click(|state: &mut AppState, _| {
+ if state.msg_tx.try_send(XilemMessage::HelloFromXilem).is_err() {
+ log::warn!("Xilem: No external receiver");
+ }
+ }),
+ ))
+}
+
+async fn external_message_sender_task(mut msg_tx: mpsc::Sender) {
+ log::debug!("Extern: start message sender task");
+ loop {
+ let timeout = TimeoutFuture::new(1_000).fuse();
+ timeout.await;
+ if msg_tx.try_send(ExternalMessage::HelloFromExtern).is_err() {
+ // The receiver within xilem is no listening anymore.
+ break;
+ }
+ }
+ log::debug!("Extern: stop message sender task");
+}
+
+async fn external_message_receiver_task(mut msg_rx: mpsc::Receiver) {
+ log::debug!("Extern: start message receiver task");
+
+ let mut tx: Option> = None;
+
+ while let Some(msg) = msg_rx.next().await {
+ match msg {
+ XilemMessage::HelloFromXilem => {
+ log::info!("Extern: Hello from Xilem :)");
+ let Some(msg_tx) = &mut tx else {
+ continue;
+ };
+ if msg_tx.try_send(ExternalMessage::HelloFromExtern).is_err() {
+ log::debug!("Extern: no receiver anymore");
+ tx = None;
+ }
+ }
+ XilemMessage::StartReceiving { msg_tx } => {
+ tx = Some(msg_tx.clone());
+ spawn_local(external_message_sender_task(msg_tx));
+ }
+ }
+ }
+ log::debug!("Extern: stop message receiver task");
+}
+
+fn main() {
+ _ = console_log::init_with_level(log::Level::Debug);
+ console_error_panic_hook::set_once();
+ log::info!("Start web application");
+
+ let (initial_state, msg_rx) = AppState::new();
+ App::new(document_body(), initial_state, app_logic).run();
+ spawn_local(external_message_receiver_task(msg_rx));
+}