🐙 tako
Tutorials

Realtime over WebSocket

Build a full-duplex WebSocket feature with an echo handler and a server-push ticker, then compare it to the SSE alternative.

This tutorial builds a realtime feature over a full-duplex WebSocket connection. It is grounded in the runnable examples/websocket crate, which serves two endpoints:

  • GET /ws/echo — reads each client message and echoes it back
  • GET /ws/tick — pushes a tick #N message to the client once per second

These are the two halves of any chat-style feature: receiving messages from a client, and pushing messages to it on a schedule or from another source. WebSocket requires the ws feature.

If you have not served a handler before, read the Quickstart first.

1. The full example

A WebSocket endpoint in Tako is an ordinary HTTP handler that returns WebSocketUpgrade::new(req, handler). The handler receives the upgraded WebSocket and drives the conversation. This is the whole file:

examples/websocket/src/main.rs
use std::time::Duration;

use tako::Method;
use tako::responder::Responder;
use tako::types::Request;
use tako::ws::Message;
use tako::ws::WebSocketUpgrade;

pub async fn ws_echo(req: Request) -> impl Responder {
  WebSocketUpgrade::new(req, |mut ws| async move {
    if ws.send(Message::text("Welcome to Tako WS!")).await.is_err() {
      return;
    }
    while let Some(Ok(message)) = ws.recv().await {
      let reply = match message {
        Message::Text(text) => Message::text(format!("Echo: {text}")),
        Message::Binary(data) => Message::Binary(data),
        _ => continue,
      };
      if ws.send(reply).await.is_err() {
        break;
      }
    }
  })
}

pub async fn ws_tick(req: Request) -> impl Responder {
  WebSocketUpgrade::new(req, |mut ws| async move {
    let mut ticker = tokio::time::interval(Duration::from_secs(1));
    let mut tick = 0u64;
    loop {
      tokio::select! {
        message = ws.recv() => match message {
          Some(Ok(_)) => {}
          _ => break,
        },
        _ = ticker.tick() => {
          if ws.send(Message::text(format!("tick #{tick}"))).await.is_err() {
            break;
          }
          tick += 1;
        }
      }
    }
  })
}

#[tokio::main]
async fn main() {
  let listener = tokio::net::TcpListener::bind("127.0.0.1:8080")
    .await
    .unwrap();
  let mut router = tako::router::Router::new();

  router.route(Method::GET, "/ws/echo", ws_echo);
  router.route(Method::GET, "/ws/tick", ws_tick);

  tako::Server::builder()
    .build()
    .spawn_http(listener, router)
    .result()
    .await
    .expect("HTTP server failed");
}

2. How the upgrade works

WebSocketUpgrade::new(req, |ws| async move { … }) is a Responder: when the handler returns it, Tako answers the WebSocket handshake on the incoming Request and then runs your closure on the upgraded connection. Inside the closure, ws.recv() returns the next inbound Message and ws.send() queues an outbound one. Queued messages go out together once the closure waits for the next message, so a burst of replies leaves in one write.

The echo handler is a straight read-respond loop:

while let Some(Ok(message)) = ws.recv().await {
  let reply = match message {
    Message::Text(text) => Message::text(format!("Echo: {text}")),
    Message::Binary(data) => Message::Binary(data),
    _ => continue,
  };
  if ws.send(reply).await.is_err() {
    break;
  }
}

Tako answers pings and the closing handshake itself, so the loop only handles text and binary messages. recv() returns None once the connection has closed.

3. Server-initiated pushes

A chat feed has to push messages the client did not ask for — a new message from another user, a periodic heartbeat. The ws_tick handler shows the pattern: tokio::select! races the next inbound message against a timer, so the task both reacts to the client (stopping when it disconnects) and pushes on its own schedule:

let mut ticker = tokio::time::interval(Duration::from_secs(1));
let mut tick = 0u64;
loop {
  tokio::select! {
    message = ws.recv() => match message {
      Some(Ok(_)) => {}
      _ => break,
    },
    _ = ticker.tick() => {
      if ws.send(Message::text(format!("tick #{tick}"))).await.is_err() {
        break;
      }
      tick += 1;
    }
  }
}

recv() is cancel-safe: when the timer wins, no inbound data is lost. To turn this into a real chat fan-out, replace the ticker arm with a tokio::sync::broadcast::Receiver: every connected socket subscribes to a shared broadcast::Sender, and posting a message sends it to every subscriber. The select! skeleton stays identical — one arm reads the client, the other reads the broadcast channel.

4. The Cargo.toml

[dependencies]
tako-rs = { version = "2.5", features = ["ws"] }
tokio = { version = "1", features = ["full"] }

The ws feature enables tako::ws, which also provides the Message type, so no other WebSocket crate is needed.

5. Running it

cargo run

Connect with any WebSocket client. With websocat:

# echo
websocat ws://127.0.0.1:8080/ws/echo
# type a line; the server replies "Echo: <line>"

# server push
websocat ws://127.0.0.1:8080/ws/tick
# prints "tick #0", "tick #1", … once per second

In the browser, the standard WebSocket API works unchanged:

const ws = new WebSocket("ws://127.0.0.1:8080/ws/echo");
ws.onmessage = (e) => console.log(e.data);
ws.onopen = () => ws.send("hello");

Tuning the connection

WebSocketUpgrade is a builder. Before returning it you can constrain the handshake and the frames — protocols, allowed_origins, max_frame_size, max_message_size, read_buffer_size, write_buffer_size, upgrade_timeout and max_lifetime. These guard against oversized frames and stalled upgrades on a public endpoint. See the WebSocket transport reference for the defaults.

The same handler runs on Compio when you enable compio next to ws. HTTP/2 WebSocket (RFC 8441) and the deflate extension are not supported; see the transport reference.

When to use SSE instead

If the data only flows server → client — a live ticker, a log tail, a progress feed — a WebSocket is more machinery than you need. Server-Sent Events give you a one-way text/event-stream from an ordinary HTTP handler, with browser auto-reconnect built in, behind the sse feature:

use futures_util::stream;
use tako::responder::Responder;
use tako::sse::{Sse, SseEvent};
use tako::types::Request;

async fn feed(_: Request) -> impl Responder {
  let events = stream::iter([
    SseEvent::data("user joined").event("presence"),
    SseEvent::data("hello").event("message"),
  ]);
  Sse::events(events)
}

Reach for a WebSocket when the client also needs to send — chat input, collaborative editing, game state. Reach for SSE when it only needs to listen.

Next steps

Last updated on

On this page