- Imagine a web app where data refreshes live without page reloads—think of stock price updates or real-time chat. Traditionally, this would require complex JavaScript frameworks or WebSockets. But with Rust, HTMX, and Server-Sent Events (SSE), you can build efficient and real-time web apps with much less overhead.
- Overview: Briefly introduce the three technologies:
- Rust: Known for performance, safety, and ease of building backends.
- HTMX: A simple way to gives you access to AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML
- SSE: A way to push updates from the server to the browser.
2. Why Rust + HTMX + SSE?
Real-time web applications have become essential in providing dynamic, interactive user experiences. Whether it’s for chat applications, live data feeds, or online gaming, real-time capabilities are now a standard expectation. In this article, I’ll walk you through building a real-time web application using Rust and the Axum framework, focusing on implementing SSE communication.
Why Rust and Axum?
Rust and Axum together provide a robust foundation for building modern, high-performance web applications. Rust’s system-level capabilities paired with Axum’s developer-friendly design offer a seamless experience for creating scalable and efficient web services.
RUST?
Rust has become a preferred language for backend web development due to its unique combination of features:
- Performance: Rust compiles to machine code, offering speeds comparable to C and C++, which is critical for handling demanding real-time applications.
- Memory Safety: Rust eliminates entire classes of bugs, such as null pointer dereferences and data races, making applications more reliable.
- Concurrency: Rust’s ownership model and concurrency tools empower developers to write multithreaded applications without the fear of race conditions.
- Growing Ecosystem: Rust supports a variety of frameworks and libraries tailored for web development, ensuring flexibility in architecture and design.
AXUM?
Axum is specifically designed to complement Rust’s strengths while simplifying web development:
- Built on Tokio: Leveraging the asynchronous capabilities of the Tokio runtime, Axum enables smooth handling of high-concurrency workloads.
- Developer Ergonomics: Axum’s focus on ergonomics makes it easy to define routes, extract request data, and integrate middleware.
- Type-Safety: With Axum, type-safety is extended across the entire web stack, reducing runtime errors and improving maintainability.
- Flexibility: Axum supports features like WebSocket, SSE, and JSON handling, making it suitable for modern web apps.
By choosing Rust and Axum, developers can build web applications that are not only fast and reliable but also scalable and easy to maintain. This combination is ideal for real-time applications requiring high throughput, security, and robust error handling.
Web Framework Benchmarks (2023-10-17)
HTMX
HTMX simplifies frontend interactions by enabling HTML responses to drive UI updates directly. This eliminates the need for complex JavaScript-heavy solutions. Key benefits include:
- Reduced JavaScript: Minimizing JavaScript reduces development complexity and improves maintainability.
- Server-Side Rendering: HTMX integrates seamlessly with server-side rendering, ensuring faster initial loads and better SEO.
- Ease of Use: Developers can use familiar HTML attributes like
hx-getandhx-postto handle dynamic interactions.
By integrating HTMX, the process becomes even more efficient, allowing developers to target specific parts of the page for updates, resulting in a better user experience with minimal client-side scripting.
Server-Sent Events (SSE)
SSE is a powerful tool for streaming real-time updates from the server to the browser, offering several advantages:
- Simplicity: SSE is easier to implement than WebSockets for one-way communication.
- Built-in Browser Support: Modern browsers natively support SSE without requiring additional libraries.
- Efficient Updates: The server pushes updates only when necessary, saving bandwidth compared to traditional polling.
- Persistent Connection: A single persistent connection ensures updates are delivered in near real-time without reconnection overhead.
- Use Cases: Ideal for stock price monitoring, live notifications, chat applications, and dashboard metrics.
“This stack leverages Rust’s power for backend processing and HTMX’s simplicity for front-end interactions, while SSE provides seamless real-time updates.”
3. Editor Tools: Your Rust Coding Allies
When working with Rust, the right editor makes all the difference. Here’s a quick look at two powerful options:
VS Code
VS Code is a go-to for Rust developers, thanks to the Rust Analyzer extension, which provides code completion, error checking, and inline hints. Its integrated terminal and debugging tools streamline your workflow, making it perfect for Rust development.
RustRover
For Rust-focused development, RustRover offers tailored features like type-aware navigation, smart error messages, and integrated Cargo tools. It’s designed by the Rust community for the Rust community, making it a great choice for deep Rust development.
Both editors provide essential tools to enhance your coding efficiency, whether you prefer the versatility of VS Code or the specialized features of RustRover.
4. Demo: Real-Time Interactivity with SSE and HTMX
How the Flow Works
- SSE Connection: The server establishes a persistent connection with the client via SSE.
- Real-Time Data: The server pushes live updates—such as newly created posts—directly to the client.
- HTMX Updates the DOM: HTMX listens for these updates and dynamically modifies only the relevant parts of the DOM, thanks to the HTMX Idiormorph Extension HTMX Idiomorph Extension.
“With SSE, the server can send real-time updates without the need for expensive polling or full page reloads, resulting in a more efficient and responsive user experience.”
4.1. Repository Structure
sse-rust-htmx
├── src/
│ ├── main.rs # Application entry point
│ ├── controllers/ # Request handlers
│ │ ├── mod.rs # Module declarations
│ │ └── home.rs # Home page controller
│ ├── views/ # HTML templates
│ │ ├── mod.rs # Module declarations
│ │ └── home.rs # Home page templates
│ └── data/ # Data management
│ ├── mod.rs # Module declarations
│ └── home.rs # Home data operations
└── Cargo.toml # Project dependencies
Description of Each Component
src/main.rs- Entry point for the application.
- Sets up the Axum server and defines the routing structure.
- Initializes the
AppStateand shares it with the routes usingaxum::extract::State
src/controllers/- Contains logic for handling routes.
- Each file corresponds to a specific domain or resource (e.g., home).
src/views/- Manages pure HTML templates or pre-defined responses for rendering.
- Ensures there’s no application logic here, only presentation logic.
src/data/- Responsible for managing in-memory data storage and publishing updates via channels.
- Includes components for storage and broadcasting changes
4.2. Setting Up a Rust Server with Axum
Step-by-Step Guide
- Initialize the Project
cargo new sse-rust-htmx cd sse-rust-htmx
- Add Dependencies Add axum, tokio, and other necessary crates to your Cargo.toml
[package] name = "sse-rust-htmx" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] axum = { version = "0.8.0-alpha.1", features = ["macros", "form"] } serde = { version = "1.0.214", features = ["derive"] } serde_json = "1.0.132" serde_qs = { version = "0.13.0", features = ["axum"] } tokio = { version = "1.0", features = ["full"] } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } html-node = "0.5.0" fake = "3.0.0" futures = "0.3.31" anyhow = "1.0.93" tokio-stream = "0.1.16" tower-http = { version = "0.6.2", features = ["fs"] } tower = { version = "0.5.1", features = ["full"] } time = { version = "0.3.36", features = ["macros", "serde"] }
- Define the Application State
Create
AppStateto store posts and manage real-time updates:- Use
Arc<Mutex<Vec<Post>>>to share posts across threads. (Struct Arc)
- Use
tokio::sync::watchfor live updates. (Crate tokio)
- Use
- Set Up Routes
- Define endpoints for the home page, real-time updates (SSE), and new post creation.
- Use Axum’s router to link handlers to these routes.
- Endpoints:
home_sse[GET]Streams real-time updates to clients via SSE whenever
postschange.home[GET]Renders the home page with the current posts dynamically.
home[POST]Accepts new posts from the client, updates the shared
postsstate, and notifies subscribers.
- Start the Server
Launch the Axum server, bind it to a port, and start listening for requests.
Code example
/// main.rs
...
#[derive(Clone)]
struct AppState {
posts: Arc<Mutex<Vec<Post>>>,
post_receiver: tokio::sync::watch::Receiver<Vec<Post>>,
}
#[tokio::main]
async fn main() {
...
let posts = Arc::new(Mutex::new(vec![]));
let mut join_set = JoinSet::new();
let post_data_source = PostDataSource::new(&mut join_set, &posts);
let app_state = AppState {
post_receiver: post_data_source.receiver,
posts,
};
let current_dir = env::current_dir().unwrap();
let lib_path = current_dir.join("src/lib");
// -- Define Routes
let app = Router::new()
.route("/", get(controller::home::home))
.route("/home", get(controller::home::home))
.route("/home/sse", get(controller::home::home_sse))
.route("/home", post(controller::home::create_post))
.nest_service("/lib", ServeDir::new(lib_path))
.with_state(app_state);
// region: --- Start Server
let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();
info!("{:<12} - {:?}\n", "LISTENING", listener.local_addr());
tracing::debug!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app.into_make_service())
.await
.unwrap();
// endregion: --- Start Server
}
Flow Summary
- Initialization: Shared state (
posts) and update notifier (watch) are set up.
- Real-Time Updates:
home_ssestreams live changes to clients.
- Dynamic Content:
homerenders HTML based on current posts.
- Data Submission:
home[POST] updates the shared state when a new post is created.
4.3. Adding HTMX to Webpage
To effectively use HTMX, you need to include the relevant HTMX scripts in your webpage. These scripts provide the functionalities required for dynamic interactions, such as SSE and DOM integration.
Steps to Include HTMX
- Include HTMX Core Library
- Add the HTMX script from htmx.org using a
<script>tag in the<head>of your HTML.
- Ensure it’s correctly loaded and available for use in your template.
- Add the HTMX script from htmx.org using a
- Include Extensions
- Idiomorph Extension: Facilitates merging and DOM integration with HTMX.
- SSE Extension: Adds support for Server-Sent Events in HTMX.
- Hyperscript: Enhances interactivity with declarative scripting.
- Other Assets
- Include stylesheets like Bootstrap for page styling.
Code Example for the Layout
The following code sets up the basic HTML layout with all required scripts and styles:
use html_node::{html, Node};
pub mod home;
fn layout(content: Node) -> Node {
html! {
<html>
<head>
<title> "Twitter clone in htmx" </title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-BmbxuPwQa2lc/FVzBcNJ7UAyJxM6wuqIj61tLrc4wSX0szH/Ev+nYRRuWlolflfl"
crossorigin="anonymous"
/>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
/lib/idiomorph-ext.min.js
https://unpkg.com/htmx.org@1.9.12/dist/ext/sse.js
https://unpkg.com/hyperscript.org@0.0.5
</head>
{content}
</html>
}
}
Integrating HTMX with SSE
Use Case:
- Add a SSE session on the home page.
- Configure HTMX extensions (
morphandsse) to handle DOM updates dynamically.
Explanation:
hx-ext="morph, sse": Activates HTMX extensions.
sse-connect="http://localhost:8080/home/sse": Defines the SSE endpoint for data streaming.
sse-swap="message": Specifies the event name that triggers DOM updates.
hx-swap: Uses themorphstrategy to merge updates into the DOM without disrupting the active state.
Code example:
...
/// Generates the HTML content for the home page.
pub fn home_page(username: &str, posts: Ref<Vec<Post>>) -> String {
println!("posts {:?}", posts.clone());
let html_content = layout(html! {
<body>
<div class="content">
<div hx-ext="morph, sse"
sse-connect="http://localhost:8080/home/sse"
sse-swap="message"
hx-select=".wrapper"
hx-swap=r#"morph:{ignoreActiveValue:true,morphStyle:'innerHTML'}"#>
<div class="wrapper">
...
</div>
</div>
</div>
</body>
});
html_content.to_string()
}
Making a POST Request with HTMX
HTMX simplifies sending data to the server with POST requests. You don’t need to write any JavaScript. Instead, define the endpoint and the form fields, and HTMX takes care of handling the request and sending it to the server.
How it work:
- Specify the Endpoint
- Use the
hx-postattribute to define the server endpoint that will handle the POST request.
- Use the
- Define Form Data
- Include form inputs (
<input>or<textarea>) with the desirednameattributes. These attributes define the key-value pairs sent to the server.
- Include form inputs (
- Define Response Handling
- Use the
hx-swapattribute to specify how the response should update the DOM.hx-swap="none": Prevents the DOM from being updated after the request.
- Use the
Code Example
The following example demonstrates a simple form for posting a message:
<div>
<!-- HTMX Form for Posting Data -->
<form hx-post="http://localhost:8080/home" hx-swap="none">
<!-- Hidden Input for Username -->
<input
data-query
type="hidden"
class="form-control"
name="username"
readonly="true"
value="{username}"
/>
<!-- Message Input -->
<div class="mb-3 row">
<label for="txtMessage">Message:</label>
<textarea
id="txtMessage"
class="form-control"
rows="3"
name="message"
required="true"
></textarea>
</div>
<!-- Submit Button -->
<div class="d-grid gap-2 col-3 mx-auto mb-3">
<button
type="submit"
class="btn btn-primary text-center"
>
Tweet
</button>
</div>
</form>
</div>
Key Points
- Form Attributes
hx-post="http://localhost:8080/home": Defines the server endpoint to send the POST request.
hx-swap="none": Ensures no DOM updates after the request. (cause SSE is manage it)
- Form Inputs
nameattributes on<input>and<textarea>define the keys for the POST payload.
- Values entered in the fields are automatically sent to the server.
- Minimal Setup
- No additional JavaScript is required; HTMX handles the form submission process seamlessly.
4.4. Managing Data to Generate HTML with Memory-Based Data
To manage in-memory data for a Rust web app with Axum, you can leverageArc<Mutex<T>>;to hold and manipulate a shared posts list. The data synchronization and live updates are achieved usingtokio::sync::watch. While this approach works well for demonstrations, in a production environment you should consider using a proper database like PostgreSQL or MongoDB for data persistence, scalability, and reliability. Here’s how it works:
Data Setup
- Shared Data Store:
- Use
Arc<Mutex<Vec<Post>>>to store posts in memory, ensuring safe, concurrent access across threads.
- Use
- Real-Time Updates:
- Create a
tokio::sync::watch::SenderandReceiverpair to monitor changes in the posts list.
- When a change occurs (e.g., a new post is added), the sender pushes updates to all subscribers.
- Create a
Code example:
/// data/post_datasource.rs
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::task::JoinSet;
use crate::data::model::Post;
pub struct PostDataSource {
pub receiver: tokio::sync::watch::Receiver<Vec<Post>>,
}
impl PostDataSource {
/// Creates a new instance of `PostDataSource`, which monitors changes to a shared
/// `Vec<Post>` and broadcasts updates to listeners through a `tokio::sync::watch::Receiver`.
///
/// # Parameters
///
/// - `join_set`: A mutable reference to a `JoinSet` that manages asynchronous tasks.
/// A task will be spawned to monitor changes to the `posts` vector and send updates.
/// - `posts`: A reference-counted, thread-safe, asynchronous `Vec<Post>` wrapped in
/// `Arc<Mutex<_>>`. This vector represents the data being monitored for changes.
///
/// # Behavior
///
/// This function:
/// 1. Spawns an asynchronous task to continuously monitor the `posts` vector for changes.
/// 2. Uses a hash of the `posts` data to detect changes.
/// 3. Sends updates to the `tokio::sync::watch::Receiver` only when the data changes,
/// avoiding redundant updates.
/// 4. Runs the monitoring loop with a one-second interval between checks to avoid busy-waiting.
///
/// # Returns
///
/// A `PostDataSource` instance that provides a `tokio::sync::watch::Receiver`
/// to listen for updates to the `posts` vector.
pub fn new(join_set: &mut JoinSet<anyhow::Error>, posts: &Arc<Mutex<Vec<Post>>>) -> Self {
let (sender, receiver) = tokio::sync::watch::channel(vec![]);
let posts_clone = posts.clone();
// Spawn a task to monitor changes to `posts` and send updates
join_set.spawn(async move {
let mut last_hash: u64 = 0; // Track the last sent posts
loop {
let mut hasher = DefaultHasher::new();
let posts_lock = posts_clone.lock().await;
posts_lock.hash(&mut hasher);
let hash = hasher.finish();
// Only send the posts if they have changed since the last send
if hash != last_hash {
sender.send_replace(posts_lock.clone());
last_hash = hash; // Update the last sent posts
}
// Sleep or wait for a signal to avoid busy-waiting
tokio::time::sleep(Duration::from_millis(1000)).await;
}
});
PostDataSource { receiver }
}
}
Handlers Overview
home_sse
- Purpose: Subscribes to changes in the posts data and streams updates to the client using Server-Sent Events (SSE).
- Workflow:
- Listen to the
Receiverfor updates.
- When notified, send HTML fragments to connected clients.
- Listen to the
home
- Purpose: Renders the home page, displaying the current state of the posts.
- Workflow:
- Read the
postslist from theArc<Mutex<Vec<Post>>>.
- Generate HTML dynamically with the latest posts.
- Read the
create_post
- Purpose: Handles new post submissions and updates the shared state.
- Workflow:
- Accept POST data.
- Update the
postslist and notify subscribers via theSender.
/// controller/home.rs
...
#[derive(Debug, Serialize, Deserialize)]
pub struct QueryParams {
username: String,
message: String,
}
/// Renders the home page as an HTML response, dynamically generating its content
/// based on the current state of the application's posts.
pub async fn home(
State(crate::AppState {
post_receiver: mut receiver,
..
}): State<crate::AppState>,
) -> Html<String> {
let username: String = Username().fake();
let content = home_page(&username, receiver.borrow_and_update());
Html(content)
}
/// Handles a SSE stream for the home page, sending updated HTML content
/// whenever the application's post data changes.
pub async fn home_sse(
State(crate::AppState {
post_receiver: mut _receiver,
..
}): State<crate::AppState>,
) -> Sse<impl Stream<Item=Result<Event, RecvError>>> {
let username: String = Username().fake();
let (sender, receiver1) = tokio::sync::mpsc::channel(1);
tokio::task::spawn(async move {
loop {
if _receiver.changed().await.is_err() {
println!("Post Receiver disconnected");
return;
}
let html = home_page(&username, _receiver.borrow_and_update());
if let Err(err) = sender.send(Ok(Event::default().data(html))).await {
println!("Failed to send event: {}", err);
return;
}
}
});
Sse::new(ReceiverStream::new(receiver1)).keep_alive(KeepAlive::default())
}
/// Handles the creation of a new post and adds it to the shared application state.
pub async fn create_post(
State(crate::AppState {
posts: state,
..
}): State<crate::AppState>,
JsonOrForm(payload): JsonOrForm<QueryParams>,
) -> Result<impl IntoResponse, StatusCode> {
let mut posts_lock = state.lock().await; // Lock the Mutex
posts_lock.push(Post {
username: payload.username.to_string(),
message: payload.message.to_string(),
time: OffsetDateTime::now_utc().to_string(),
avatar: format!("https://ui-avatars.com/api/?background=random&rounded=true&name= {}", payload.username.to_string()),
});
Ok(StatusCode::OK)
}
5. Challenges and Tips
Handling Unwanted Re-renders in HTMX
One of the trickiest aspects of integrating SSE with HTMX is avoiding unwanted re-renders that disrupt user interaction, such as resetting input fields mid-typing. Here’s how to keep your app smooth and user-friendly:
- Preserve User State: Use the HTMX Idiormorph Extension to maintain user inputs or dynamic states during re-renders. It’s like telling your app, “Hey, don’t mess with the stuff users are still working on!”
Example: Apply
hx-swap="morph:{ignoreActiveValue:true,morphStyle:'innerHTML'}"to prevent overwriting active input values during updates.
- Target Specific Elements: Update only the parts of the page that actually need it. Use
hx-select=".wrapper"to specify which element to swap, avoiding a full-page refresh. This saves bandwidth and reduces flickering.
Optimizing Performance in Rust
Rust’s combination of speed and memory safety makes it an ideal match for handling large-scale real-time updates. However, a few best practices can help you squeeze every ounce of efficiency out of your code:
- Minimize Cloning: Cloning data can be tempting but is often unnecessary. Instead, use
Arcto safely share data across threads without duplicating it.Example: Instead of
let new_data = data.clone();, opt forlet shared_data = Arc::clone(&data);to reduce memory overhead.
- Go Async for Scalability: Rust’s async/await features let you manage thousands of SSE connections without breaking a sweat. By making your functions asynchronous, you avoid blocking threads, allowing the server to handle multiple users concurrently with ease.
6. Conclusion
New Techniques Worth Trying
- Rust: Coming from someone who struggled with the borrow checker for months, I’ve grown to love Rust’s strict but protective nature. After battling through the initial learning curve, I’m now a huge fan of how it helps me write more reliable and efficient code.
Key Pain Point: Borrow checker anxiety. It’s like having an overprotective friend who won’t let you borrow their car because you might forget to return it, even though you just needed it to grab groceries down the street. But hey, once you tame it, you realize they were right all along. No crashes, no null pointer panic attacks, and your code is basically bulletproof.
- Rust + HTMX + SSE: A dream team for building efficient, real-time web applications. Rust’s unmatched performance and safety, combined with HTMX’s ease of handling dynamic content and SSE’s lightweight real-time communication, create a stack that is both powerful and scalable. Whether you’re building a live-feed app or a responsive user interface, this combination makes real-time interactivity a breeze.
- Challenge Yourself: Dive deeper into these technologies and experiment with creating your own real-time applications. From live-updating dashboards to collaborative tools, the possibilities are vast. Embrace the learning curve—it’s worth it!
Future Work
Looking forward, there are several enhancements and areas for growth to further improve and scale the application:
- Optimizing for Large-Scale Applications: To handle large volumes of concurrent users, implement strategies like caching, load balancing, and orizontal scaling.
- Example: Use Redis for caching frequently accessed pos. This setup helps distribute traffic efficiently, improving system responsivenes.
- Hybrid Real-Time Approaches: Explore combining SSE with other real-time communication technologies like WebSockets or GraphQL subscriptions to tailor solutions for specific use cases.
- Example: If your application requires bidirectional communication (e.g., chat or collaborative editing), consider integrating WebSockets alongside SSE to facilitate both server-to-client and client-to-server messaging.
- Database Integration: Instead of storing posts in memory, implement a persistent database to store and manage posts. This will ensure data persistence and scalability.
- Example: Use PostgreSQL or MongoDB to store posts. On receiving a new post, the server could insert it into the database and then broadcast the update to connected clients via SSE. This ensures that data is not lost and can be retrieved after server restarts, making the system more robust.
7. Code Repository and Further Reading
- Code Repository:
Explore the complete implementation on GitHub: rust-htmx repository
- Further Reading:
To deepen your understanding, consider these resources:
Thank you for reading this post. Hopefully you guys have fun exploring these new technologies
!😎😎😎!