Typed WebSockets in Dioxus 0.7: A Small Fullstack Rust Example

A small practical example of typed WebSockets in Dioxus 0.7, using fullstack Rust patterns for realtime app features.

WebSockets are one of those features that look simple until your app has to keep frontend and backend message shapes in sync.

Sending "hello" over a socket is easy. Maintaining a growing pile of loosely shaped JSON messages across client code, server handlers, UI state, and tests is where the fun starts to rot.

That is why the WebSocket support in Dioxus 0.7 is interesting. Dioxus Fullstack builds on top of Axum’s WebSocket API, but adds the thing Rust developers usually want: shared types.

Instead of treating the socket as a string tunnel, you can model the messages flowing in both directions.

That sounds small. It is not.

It means your realtime feature can start with actual Rust types instead of “please remember this payload shape in three places”.

Why WebSockets?

Most web apps can get pretty far with plain HTTP.

A form submit is fine when the user sends something once and waits for a result. A normal API call is fine when the UI needs to fetch a list, update a record, or save a setting.

WebSockets become useful when the connection itself matters.

Examples:

  • a feedback widget receiving live replies
  • a build log streaming output
  • a multiplayer or collaborative UI
  • progress updates for long-running jobs
  • notifications that should arrive without polling
  • a dashboard that reflects backend state quickly

You can fake some of this with polling. Sometimes polling is the right boring answer.

But once the client and server need to talk repeatedly, in order, over the same session, a WebSocket is the better primitive.

The Dioxus 0.7 shape

Dioxus Fullstack provides WebSocket support that works alongside server functions.

The important pieces are:

  • WebSocketOptions
  • Websocket
  • options.on_upgrade(...)
  • typed input and output messages
  • optional custom encoding

At the simplest level, a server function accepts WebSocketOptions and returns a Websocket.

The official Dioxus docs show the basic idea with an uppercase echo socket:

#[get("/api/uppercase_ws")]
async fn uppercase_ws(options: WebSocketOptions) -> Result<Websocket> {
    Ok(options.on_upgrade(move |mut socket| async move {
        // Send an initial greeting.
        _ = socket.send("Hello!".to_string()).await;

        // Echo messages back in uppercase.
        while let Ok(msg) = socket.recv().await {
            _ = socket.send(msg.to_ascii_uppercase()).await;
        }
    }))
}

That already gives you a useful realtime channel.

The server upgrades the HTTP request to a WebSocket connection, then keeps receiving and sending messages while the socket is alive.

Under the hood, Dioxus wraps Axum’s WebSocket upgrade flow. The nice part is that you do not have to drop out of the Dioxus Fullstack model just because one part of your app needs bidirectional communication.

The default Websocket type

The Websocket type is generic over three parameters:

pub struct Websocket<In = String, Out = String, E = JsonEncoding> {
    // ...
}

Those parameters are:

  1. the type received from the client
  2. the type sent back to the client
  3. the encoding used for messages

By default, both message directions use String, encoded as JSON.

That is fine for a toy echo example. It is less nice once your feature has real events.

For real app code, I usually want explicit event types.

Not because enums are fancy. Because debugging random string protocols at 1am is a self-inflicted wound.

Adding typed messages

Let’s say the client sends text input and the server responds with the uppercase version.

We can model that as two enums:

use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug)]
enum ClientEvent {
    TextInput(String),
}

#[derive(Serialize, Deserialize, Debug)]
enum ServerEvent {
    Uppercase(String),
}

Now the WebSocket function can return a typed socket:

#[get("/api/uppercase_ws")]
async fn uppercase_ws(
    options: WebSocketOptions,
) -> Result<Websocket<ClientEvent, ServerEvent>> {
    Ok(options.on_upgrade(move |mut socket| async move {
        while let Ok(event) = socket.recv().await {
            match event {
                ClientEvent::TextInput(text) => {
                    let response = ServerEvent::Uppercase(text.to_ascii_uppercase());
                    _ = socket.send(response).await;
                }
            }
        }
    }))
}

This is the part worth caring about.

The socket is no longer “some text comes in, some text goes out”.

It is:

ClientEvent -> ServerEvent

The compiler now knows what the protocol looks like.

If you add a new client event later, Rust can help you find the places that need to handle it. If the server sends a response variant the UI does not expect, that mismatch becomes visible in code instead of hiding in runtime behavior.

That is the boring magic of fullstack Rust.

A more realistic event shape

The uppercase example is useful because it is tiny. But most actual realtime features are event streams.

For example, a feedback widget might send events like this:

#[derive(Serialize, Deserialize, Debug)]
enum ClientEvent {
    DraftChanged {
        text: String,
    },
    FeedbackSubmitted {
        page_url: String,
        message: String,
    },
}

And the server might respond with:

#[derive(Serialize, Deserialize, Debug)]
enum ServerEvent {
    Connected {
        session_id: String,
    },
    DraftAccepted,
    FeedbackReceived {
        id: String,
    },
    Error {
        message: String,
    },
}

That already reads like a small protocol.

And because it is plain Rust, you can keep it boring:

  • simple enums
  • simple structs
  • no mystery strings
  • no undocumented JSON blobs
  • no “frontend knows this by convention” nonsense

This is the part where Dioxus starts to feel different from a typical frontend/backend split. The message contract can live close to both sides of the feature.

WebSockets are stateful

There is one caveat you should not ignore: WebSockets are stateful.

A WebSocket connection ties a client and server together for a session. That is the point. It is also the cost.

This matters when deploying.

If your app runs in an environment where requests are short-lived, serverless, or aggressively moved around, you need to think carefully about where the connection lives and how session state is preserved.

For a normal long-running Axum-style service, this is usually fine.

For serverless environments with request time limits, it can get awkward quickly.

That is not a Dioxus problem. That is WebSockets being WebSockets.

Encoding choices

Dioxus also lets you customize the encoding.

The default is JSON, which is the right choice when compatibility matters. If third-party clients might connect, JSON is boring and useful.

For Rust-only clients, binary encodings like CBOR or MsgPack can make more sense.

The type shape looks like this:

Websocket<ClientEvent, ServerEvent, CborEncoding>

I would not start there unless you have a reason.

JSON first. Optimize later. Future-you has enough problems.

Where this is actually useful

Typed WebSockets are not something every app needs on day one.

But they become valuable the moment “submit and wait” stops being enough.

A few places where this pattern fits well:

Live feedback

For a feedback product like SeggWat, WebSockets could support richer interactions than a plain form submit.

For example:

  • user starts typing feedback
  • widget validates or classifies input
  • server sends acknowledgement
  • dashboard receives the new feedback event
  • support/admin UI updates without refresh

Not every feedback flow needs that. A calm form is often better than a hyperactive realtime UI.

But once feedback becomes part of a live product surface, the protocol matters.

Build logs and job progress

If your app starts long-running jobs, WebSockets are a natural fit.

The client sends:

ClientEvent::StartJob { id }

The server streams:

ServerEvent::JobStarted
ServerEvent::LogLine(...)
ServerEvent::Progress(...)
ServerEvent::JobFinished

That is much cleaner than hammering /status every second and hoping the UI catches up.

Collaborative interfaces

Anything collaborative eventually needs a shared event model.

Even if you do not build full Google Docs-style collaboration, small shared-state features benefit from typed messages:

  • presence
  • cursor position
  • comments
  • edit notifications
  • shared dashboard updates

Again, the win is not that WebSockets exist. The win is that the messages are modeled directly.

Keep the protocol boring

The biggest mistake with realtime features is getting clever too early.

A good WebSocket protocol should be almost dull.

Prefer this:

enum ClientEvent {
    MessageSent { text: String },
}

enum ServerEvent {
    MessageAccepted { id: String },
    MessageRejected { reason: String },
}

Over this:

struct SocketPayload {
    kind: String,
    data: serde_json::Value,
}

The second version feels flexible. It is also where type safety goes to die quietly behind the shed.

Use serde_json::Value at the edges if you must. Do not make it the center of your app protocol unless you enjoy writing bugs with both hands.

Practical gotchas

A few things worth remembering:

Handle disconnects normally

A WebSocket can close. The network can vanish. A laptop can sleep. A browser tab can disappear.

Design the server loop assuming recv() eventually fails.

while let Ok(event) = socket.recv().await {
    // handle event
}

When the loop ends, clean up anything tied to that connection.

Do not hide all errors

The examples often use:

_ = socket.send(response).await;

That keeps the sample small. In production code, you may want to log send failures or break out of the loop when the connection is gone.

Avoid huge message types

Typed does not mean enormous.

Keep messages focused. If one enum variant becomes half your app state, split the feature.

Think before using WebSockets on serverless

WebSockets want a persistent connection. Some serverless platforms support that. Some make it painful. Some technically support it but make the architecture weird.

If the deployment target fights stateful connections, do not pretend it is fine. Pick SSE, polling, a queue, or different infrastructure.

Why I like this pattern

The nice thing about Dioxus Fullstack is not that it removes the frontend/backend boundary completely.

It does not. The boundary is still there.

The nice thing is that the boundary can become typed.

That matters most in the small glue code where web apps usually get messy:

  • request payloads
  • response shapes
  • socket messages
  • form types
  • route params
  • optimistic UI state

WebSockets make that mess more visible because the communication is long-lived. If the protocol is vague, the feature gets vague too.

Dioxus gives you a way to keep it explicit.

Client sends ClientEvent.

Server sends ServerEvent.

Encoding is declared.

The connection upgrade is handled through the fullstack layer.

That is a nice default.

Final thought

Typed WebSockets are not magic. You still have to handle deployment, disconnects, errors, and state.

But they remove one very annoying class of bugs: the kind where the frontend and backend silently disagree about what a message is supposed to look like.

For realtime features, that is a big deal.

And for fullstack Rust, it is exactly the kind of boring correctness that makes the stack worth using.

References