Publishing and consuming events from an Axum service with Encore, NATS, RabbitMQ, Kafka or Redis, plus how to handle local brokers, retries, tests and production.
Axum handles routing, extractors and shared state, and when a handler needs to tell other parts of the system that something happened, such as sending a welcome email after a signup, the work moves out of the request and onto a message broker. Adding Pub/Sub means choosing a broker and a client crate, passing the client to handlers through State, running subscriber loops on the same Tokio runtime as the server, deciding how failed messages are retried and keeping a broker running locally and in tests. This guide goes through Encore, NATS, RabbitMQ, Kafka and Redis with working code and then looks at what changes in production.
What you need to decide:
State.Encore combines the topic, the subscriber and the broker into static declarations in the Axum crate you already have, added through an infrastructure SDK rather than a new framework, which Encore finds through static analysis and then runs locally under encore run and provisions on deploy as SNS and SQS in your AWS account or Pub/Sub in your GCP account. Routing stays in Axum, and handlers publish through the topic directly, so there is no client to construct at startup, no connection string to manage and no State to thread through the router.

A topic is typed by the message it carries, and a subscription names the topic and the async function that handles each message:
use axum::{extract::Path, http::StatusCode, routing::post, Router};
use encore::pubsub::{Message, Subscription, Topic};
use encore::storage::postgres::Database;
use serde::{Deserialize, Serialize};
static DB: Database = Database::new("users").migrations("./migrations");
/// Published when a user is added.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct UserAdded {
name: String,
}
static USER_ADDED: Topic<UserAdded> = Topic::new("user-added");
// Started with the app by encore::initialize().
#[encore::pubsub::register]
static GREET: Subscription<UserAdded> = Subscription::new(&USER_ADDED, "greet", greet);
// Must be safe to retry: delivery is at least once, so the same user can
// arrive more than once. The welcomed table records who already got an email.
async fn greet(msg: Message<UserAdded>) -> Result<(), encore::Error> {
let name = &msg.data.name;
if DB
.query_opt("SELECT 1 FROM welcomed WHERE name = $1", &[name])
.await?
.is_some()
{
return Ok(());
}
send_welcome_email(name).await?;
DB.execute(
"INSERT INTO welcomed (name) VALUES ($1) ON CONFLICT (name) DO NOTHING",
&[name],
)
.await?;
Ok(())
}
// Placeholder for your email provider.
async fn send_welcome_email(name: &str) -> Result<(), encore::Error> {
println!("sending welcome email to {name}");
Ok(())
}
async fn add_user(Path(name): Path<String>) -> StatusCode {
// Idempotent, so a client that retries after a 500 does not add a second row.
if DB
.execute(
"INSERT INTO users (name) VALUES ($1) ON CONFLICT (name) DO NOTHING",
&[&name],
)
.await
.is_err()
{
return StatusCode::INTERNAL_SERVER_ERROR;
}
// Published after the insert has committed.
match USER_ADDED.publish(&UserAdded { name }).await {
Ok(_) => StatusCode::CREATED,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
#[tokio::main]
async fn main() {
encore::initialize();
let app = Router::new().route("/users/{name}", post(add_user));
let addr = std::env::var("ENCORE_LISTEN_ADDR").unwrap_or("0.0.0.0:8080".into());
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(encore::shutdown::signal())
.await
.unwrap();
std::process::exit(encore::shutdown::drained().await.code());
}
greet for each message.The event is published only after the insert has committed, so a failed publish returns a 500 for a row that already exists. Because the insert ignores a duplicate name, the client can retry, and the retry publishes the event again, which is why the subscriber checks the welcomed table before sending. When every committed row must produce an event even if the process dies in between, write the event to an outbox table in the same transaction as the row and publish from that table instead.
Retries, backoff, the ack deadline and concurrency are options on the subscription declaration, so tuning them means updating the existing GREET declaration rather than adding a second subscription, which would run its own handler and send every welcome email twice. A handler that returns an error is delivered again with backoff until the retry limit is reached:
use std::time::Duration;
#[encore::pubsub::register]
static GREET: Subscription<UserAdded> = Subscription::new(&USER_ADDED, "greet", greet)
.ack_deadline(Duration::from_secs(60))
.min_backoff(Duration::from_secs(5))
.max_retries(5)
.max_concurrency(4);
When messages that share a key must be handled in the order they were published, an OrderedTopic takes the ordering key on publish:
use encore::pubsub::OrderedTopic;
static AUDIT: OrderedTopic<UserAdded> = OrderedTopic::new("audit");
async fn announce(name: &str) -> Result<(), encore::Error> {
let event = UserAdded { name: name.to_string() };
USER_ADDED.publish(&event).await?;
AUDIT.publish(&event, name).await?;
Ok(())
}
Under encore test no subscription is started and each test publishes to topics of its own, so a test reads back what the code under test published and calls the handler itself when it wants it run:
#[tokio::test]
async fn nothing_handles_a_message_until_the_test_does() {
announce("ada").await.unwrap();
let published = encore::testing::published(&USER_ADDED).unwrap();
assert_eq!(published.len(), 1);
assert_eq!(published[0].data.name, "ada");
greet(published[0].clone()).await.unwrap();
}
static declarations next to the code that publishes and handles the messages.encore run runs the topics and subscriptions locally, so there is no broker container or connection string.encore::testing::published without a broker.Messages are JSON-encoded by default from Serde types, and Raw passes bytes through unchanged, so a topic shared with a non-Rust service needs both sides to agree on the format. The same SDK works with Actix Web and Rocket, which Axum vs Actix Web vs Rocket compares. Rust support is in beta, and you can sign up for access.
NATS is a small, fast broker where publishers send to subjects and subscribers receive from them, and the async-nats crate is its official Tokio client.
use axum::{extract::{Path, State}, http::StatusCode, routing::post, Router};
use futures::StreamExt;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct UserAdded {
name: String,
}
async fn add_user(
State(nats): State<async_nats::Client>,
Path(name): Path<String>,
) -> StatusCode {
let payload = match serde_json::to_vec(&UserAdded { name }) {
Ok(p) => p,
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR,
};
match nats.publish("user-added", payload.into()).await {
Ok(()) => StatusCode::CREATED,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let url = std::env::var("NATS_URL").unwrap_or("nats://localhost:4222".into());
let nats = async_nats::connect(url).await?;
// The subscriber runs on the same runtime as the server.
let mut sub = nats.subscribe("user-added").await?;
tokio::spawn(async move {
while let Some(msg) = sub.next().await {
if let Ok(event) = serde_json::from_slice::<UserAdded>(&msg.payload) {
println!("greeting {}", event.name);
}
}
});
let app = Router::new()
.route("/users/{name}", post(add_user))
.with_state(nats);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
For local development you also need a broker running:
async_nats::Client is cheap to clone, so it fits Axum State directly.Core NATS delivers at most once, so a subscriber that is down or crashes misses messages, and anything that must not be lost needs JetStream streams and consumers, which adds configuration for retention, acknowledgement and redelivery that you manage yourself.
RabbitMQ routes messages from exchanges to queues, and the lapin crate is the common async AMQP client for Rust.
lapin exposes AMQP closely, so declaring exchanges, queues and bindings and handling channel errors and reconnects is code you write, typically with a connection pool such as deadpool-lapin shared through State.
KafkaApache Kafka stores messages in partitioned, persistent logs that consumers read at their own offsets, and rdkafka wraps the librdkafka C library with an async FutureProducer and StreamConsumer.
rdkafka builds librdkafka from C sources, which adds a C toolchain and CMake to your build, and Kafka has no per-message retry, so failed messages go to a retry or dead-letter topic that you implement.
Redis channels deliver each published message to whoever is subscribed at that moment, and the redis crate supports them over an async connection.
Messages are not stored, so a subscriber that is offline or slow loses them, and work that must complete fits better on Redis Streams or one of the brokers above.
With NATS, RabbitMQ, Kafka or Redis, production means running the broker as a cluster or buying a hosted one, putting its URL and credentials in secrets, configuring networking so every service can reach it, creating streams, queues or topics and their dead-letter destinations, and monitoring consumer lag, and many teams describe that setup in Terraform.
With Encore, the topic and subscription declarations in the code are what get provisioned, so a deploy to AWS creates SNS topics and SQS queues with dead-letter queues and a deploy to GCP creates Pub/Sub topics and subscriptions with dead-letter topics, each with IAM permissions scoped to the services that publish and subscribe.
Rust support in Encore is in beta, and you can sign up for access if you want to try it with your Axum application.
For a self-managed setup, NATS with the async-nats crate is the simplest, with the client stored in Axum State and subscribers spawned as Tokio tasks. RabbitMQ with lapin fits when you need queues with acknowledgements and dead-lettering, Kafka with rdkafka fits high-volume event streams, and Encore fits if you want topics and subscriptions declared in your Rust code and the broker provisioned for you locally and in your AWS or GCP account.
Spawn the consumer loop with tokio::spawn before calling axum::serve, so the HTTP server and the subscriber share the runtime. With Encore, a subscription marked with #[encore::pubsub::register] is started by encore::initialize() with the app.
With NATS core you implement retries yourself or use JetStream consumers with acknowledgements and redelivery. RabbitMQ requeues unacknowledged messages and can route rejected ones to a dead-letter exchange. Encore retries a subscription handler that returns an error, with backoff and a retry limit set in the declaration.
Usually with Docker, for example docker run -p 4222:4222 nats or a RabbitMQ or Kafka container. With Encore, encore run runs the declared topics and subscriptions locally, so there is no broker container to manage.
With a self-managed broker you run it in Docker or Testcontainers, or put the publisher behind a trait you can mock. Under encore test no subscription runs and each test reads back what it published with encore::testing::published, then calls the handler itself if it wants it run.
Add the encore-dev crate to the Axum app you already have, call encore::initialize() before axum::serve, and declare Database::new, Topic::new and Subscription::new as static values. encore run runs the database, topic and subscription locally, and a deploy provisions them as RDS and SNS/SQS on AWS or Cloud SQL and Pub/Sub on GCP.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.