DEV Community

Cover image for That-Real-Time-Headache-Its-Not-The-WebSockets-Its-Your-Framework
member_40446303
member_40446303

Posted on

That-Real-Time-Headache-Its-Not-The-WebSockets-Its-Your-Framework

GitHub Home

That Real-Time Headache? It's Not The WebSockets, It's Your Framework 🀯

I remember a few years ago, I was leading a team to develop a real-time stock ticker dashboard. πŸ“ˆ Initially, everyone's enthusiasm was incredibly high. We were all excited to build a "live" application with our own hands. But soon, we found ourselves stuck in the mud. The tech stack we chose performed reasonably well for ordinary REST APIs, but as soon as WebSockets came into the picture, everything became unrecognizable.

Our codebase split into two worlds: the "main application" that handled HTTP requests, and a "separate module" that handled WebSocket connections. Sharing state between these two worlds, like a user's login information, became a nightmare. We had to resort to some very clever (or rather, ugly) methods, like using Redis or a message queue to synchronize data. πŸ› The code became increasingly complex, and bugs multiplied. In the end, although we delivered the product, the entire development process felt like a long and painful tooth extraction. 🦷

This experience taught me a profound lesson: for modern web applications that require real-time interaction, how a framework handles WebSockets directly determines the development experience and the ultimate success or failure of the project. Many frameworks claim to "support" WebSockets, but most of them simply "weld" a WebSocket module onto the main framework. This "grafted" solution is often the root of all our headaches. Today, I want to talk about how a well-designed framework elevates WebSockets from a "second-class citizen" to a "first-class citizen" on equal footing with HTTP. 😎

Common Symptoms of "Grafted" WebSockets

Let's first look at the problems that these "grafted" solutions typically cause. Whether in the Java world or the Node.js world, you've likely seen similar design patterns.

Symptom 1: A Divided World

In Java, you might use JAX-RS or Spring MVC to build your REST APIs, but to handle WebSockets, you need to use a completely different API, like javax.websocket and the @ServerEndpoint annotation.

// JAX-RS REST Endpoint
@Path("/api/user")
public class UserResource {
    @GET
    public String getUser() { return "Hello User"; }
}

// WebSocket Endpoint
@ServerEndpoint("/ws/chat")
public class ChatEndpoint {
    @OnOpen
    public void onOpen(Session session) { /* ... */ }

    @OnMessage
    public void onMessage(String message, Session session) { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

See that? UserResource and ChatEndpoint seem to live in two parallel universes. They have their own lifecycles, their own annotations, and their own ways of injecting parameters. Want to get the current user's authentication information in ChatEndpoint? In UserResource, this might just require a @Context SecurityContext annotation, but here, you might have to go to great lengths to access the underlying HTTP Session, and often, the framework won't even let you get it easily. 😫

In Node.js, the situation is similar. You set up your web server with Express, and then you need a library like ws to handle WebSockets.

const express = require('express');
const http = require('http');
const WebSocket = require('ws');

const app = express();

app.get('/api/data', (req, res) => {
  res.send('Some data');
});

const server = http.createServer(app);
const wss = new WebSocket.Server({ server });

wss.on('connection', (ws) => {
  ws.on('message', (message) => {
    console.log('received: %s', message);
  });
});

server.listen(8080);
Enter fullscreen mode Exit fullscreen mode

Same problem: app.get and wss.on('connection') are two completely different sets of logic. How do they share middleware? For example, if you want to use an Express authentication middleware to protect your WebSocket connection, can you do it directly? The answer is no. You need to find workarounds, manually invoking the Express middleware when the WebSocket's upgrade request is being processed, which is a very cumbersome process.

Symptom 2: The Challenge of State Sharing

The core of a real-time application is state. You need to know which user corresponds to which WebSocket connection, which channels the user is subscribed to, and so on. In a divided world, sharing this state becomes extremely difficult. Your REST API handles user login and saves session information in the HTTP session storage. Can your WebSocket module access it directly? Usually not. So, you are forced to introduce external dependencies, like Redis, to act as a "state intermediary" between the two worlds. This not only increases the complexity and operational cost of the system but also introduces new potential points of failure. πŸ’”

The Hyperlane Way: A Natural Unity 🀝

Now, let's see how a natively integrated WebSocket framework solves these problems from the ground up. In Hyperlane, a WebSocket handler, just like any other HTTP route handler, is simply a regular async function that receives a Context object. They are natural "siblings," not distant relatives.

// main.rs

// HTTP GET route
async fn http_route(ctx: Context) { /* ... */ }

// WebSocket route
async fn websocket_route(ctx: Context) { /* ... */ }

// SSE route
async fn sse_route(ctx: Context) { /* ... */ }

// ... register them in the main function ...
server.route("/api/data", http_route).await;
server.route("/ws/realtime", websocket_route).await;
server.route("/sse/stream", sse_route).await;
Enter fullscreen mode Exit fullscreen mode

The beauty of this design lies in its consistency. Once you learn how to write middleware, handle requests, and manipulate the Context for an HTTP route, you automatically know how to do the same for a WebSocket route. The learning curve is almost zero!

Shared Middleware? A Piece of Cake!

Remember the auth_middleware we wrote in the previous article? It passes user information through the Context's attributes. Now, we can apply it directly to our WebSocket route without any modifications!

// In the main function
// ...
server.request_middleware(auth_middleware).await; // Global authentication middleware

server.route("/api/secure-data", secure_http_route).await;
server.route("/ws/secure-chat", secure_websocket_route).await; // ✨ Protected by the same middleware
Enter fullscreen mode Exit fullscreen mode

When a WebSocket connection request comes in, it is first an HTTP Upgrade request. Our auth_middleware will run normally, check its token, and if validated, put the User information into the Context. Then, inside secure_websocket_route, we can safely retrieve the user information from the Context and bind this WebSocket connection to that user. The whole process is seamless, without any "glue code." This is just so cool! 😎

A Unified API: The Magic of send_body

Hyperlane also pursues this unity in its API design. Whether you are sending a regular HTTP response body, an SSE event, or a WebSocket message, you use the same method: ctx.send_body().await.

Let's look at a simple WebSocket echo server example:

pub async fn websocket_echo_handler(ctx: Context) {
    // This is a simplified example; in a real application, you would need a loop to continuously handle messages
    // The framework handles the protocol upgrade handshake
    println!("WebSocket connection established!");

    // Read the first message sent by the client
    let request_body: Vec<u8> = ctx.get_request_body().await;
    println!("Received a message: {:?}", request_body);

    // Send the same message back
    let _ = ctx.set_response_body(request_body).await.send_body().await;
    println!("Echoed the message back.");

    // In a real application, you would enter a loop, continuously reading and sending messages
    // loop {
    //     let msg = ctx.get_request_body().await;
    //     let _ = ctx.set_response_body(msg).await.send_body().await;
    // }
}
Enter fullscreen mode Exit fullscreen mode

The framework handles all the complexities of the WebSocket protocol for you under the hood (like message framing, masking, etc.). You only need to care about the business data (Vec<u8>) you want to send. This abstraction allows developers to focus on business logic rather than protocol details.

Broadcasting? Of Course!

The documentation even shows us the way to implement a chat room broadcast feature. By using a helper crate like , we can easily distribute messages to all connected clients. The documentation also kindly provides an important technical tip:

This kind of "veteran" advice helps developers avoid common pitfalls. This is exactly what a mature framework should be: it not only gives you powerful tools but also tells you the best practices for using them. πŸ‘

Stop Letting Your Framework Hold You Back

Real-time functionality should no longer be a "special problem" in web development. It is a core component of modern applications. If your framework is still making you handle WebSockets in a completely different, fragmented way, then it may no longer be suitable for this era.

A truly modern framework should seamlessly integrate real-time communication into its core model. It should provide a consistent API, a shareable middleware ecosystem, and a unified state management mechanism. Hyperlane shows us this possibility.

So, the next time you have a headache developing real-time features, please consider that the problem may not be with WebSockets themselves, but with the outdated framework you've chosen that still operates with a "grafted" mindset. It's time for a change! πŸš€

GitHub Home

Top comments (0)