DEV Community

Cover image for Why I Ditched Socket.IO for Raw WebSockets (And What I Learned)
Nikhil Sharma
Nikhil Sharma

Posted on

Why I Ditched Socket.IO for Raw WebSockets (And What I Learned)

When you google "how to build a chat app in Node.js," the very first result will almost certainly point you to Socket.IO. It is the de facto standard for a reason. When I started my project, I used it without a second thought. It worked like magic.

But as I got deeper into the project, that magic started to feel more like a black box. I eventually ripped out Socket.IO and replaced it with raw, native WebSockets. It was a daunting decision, but having built and managed it myself, I have some strong opinions on what Socket.IO abstracts away, what I had to build from scratch, and whether the headache was actually worth it.

The Magic of Socket.IO (And Why We Use It)

To understand why walking away from Socket.IO is hard, you have to understand exactly how much heavy lifting it does for you behind the scenes. It isn't just a WebSocket library; it is a real-time framework.

  1. The Polling Fallback: Historically, if a user's corporate firewall blocked WebSockets, Socket.IO would seamlessly downgrade to HTTP long-polling.
  2. Automatic Reconnections: If a user drives through a tunnel and loses the connection, Socket.IO automatically handles the exponential backoff to reconnect them when they emerge.
  3. Rooms and Namespaces: It gives you a beautiful socket.to("room-1").emit() API for broadcasting messages to specific groups of users.
  4. Heartbeats: It manages ping/pong messages under the hood to ensure the connection hasn't silently died.

When you drop Socket.IO, you lose all of this for free.

So, Why Did I Walk Away?

First, the fallback mechanism is largely a relic of the past. Today, native WebSocket support across modern browsers and network infrastructure is essentially ubiquitous. I didn't need to ship a massive client bundle just to support HTTP polling for the 0.1% of edge cases.

Second, the lock-in is real. If you use Socket.IO on the client, you must use a Socket.IO server implementation. You can't just connect to a standard WebSocket server. I wanted the freedom to swap out my backend without being tied to the Socket.IO ecosystem.

Most importantly, I hated not understanding my own system. When a connection failed in Socket.IO, debugging the complex state machine of fallbacks and upgrades was a nightmare. I wanted full control over the wire.

Building It Myself: What I Had to Think About

Moving to raw WebSockets meant I was suddenly responsible for the entire connection lifecycle. Here is exactly what I had to build to replicate the "magic."

1. The Reconnection Engine

Native WebSockets do not reconnect when they drop. If the connection closes, it stays closed. I had to write a custom wrapper that implemented exponential backoff.

// Initializing refs to track reconnection state
const reconnectAttemptsRef = useRef(0);
const reconnectTimeoutRef = useRef<number | null>(null);
const shouldReconnectRef = useRef(true);

const connect = () => {
  const socket = new WebSocket(WS_URL);

  socket.onopen = () => {
    // Reset backoff attempts on successful connection
    reconnectAttemptsRef.current = 0;
  };

  // ... (message handling logic)

  socket.onclose = () => {
    // Don't reconnect if the component is unmounting
    if (!shouldReconnectRef.current) return;

    // Exponential backoff: 1s, 2s, 4s, 8s, up to a maximum of 30s
    const delay = Math.min(1000 * 2 ** reconnectAttemptsRef.current, 30000);
    reconnectAttemptsRef.current++;

    reconnectTimeoutRef.current = window.setTimeout(() => {
      connect();
    }, delay);

    console.log(`Reconnecting in ${delay}ms...`);
  };
};

// Cleanup on unmount
useEffect(() => {
  // ...
  return () => {
    shouldReconnectRef.current = false;
    if (reconnectTimeoutRef.current !== null) {
      clearTimeout(reconnectTimeoutRef.current);
    }
    socketRef.current?.close();
  };
}, [user]);
Enter fullscreen mode Exit fullscreen mode

2. Custom Payload Routing

Socket.IO lets you do socket.on('new_message', handler). Raw WebSockets only give you a single onmessage event with a text payload. I had to build my own event router using standard JSON parsing and switch statements.

ws.onmessage = (event) => {
  const payload = JSON.parse(event.data);

  switch (payload.type) {
    case 'NEW_MESSAGE':
      handleNewMessage(payload.data);
      break;
    case 'USER_TYPING':
      handleUserTyping(payload.data);
      break;
  }
};
Enter fullscreen mode Exit fullscreen mode

Wrapping up

You may ask was it worth it?
I would say it absolutely was.

Yes, it took a few days to write robust wrapper classes for reconnects and message routing. But once it was built, the benefits were immediate.

My client bundle size dropped significantly. My backend was no longer coupled to a specific framework, allowing me to route raw WebSockets through standard load balancers with ease. But the biggest benefit was mental clarity. When a connection drops now, there is no magic "black box" trying to fix it behind the scenes. I know exactly how my code reacts, I know exactly what bytes are going over the wire, and debugging is as simple as reading my own logs.

If you are at a hackathon and need real-time features done yesterday, use Socket.IO. It is brilliant for rapid prototyping. But if you are building a production system and want deep, fundamental control over your architecture, raw WebSockets are nowhere near as scary as people make them out to be. Doing it the hard way is how you actually learn the web.

Again, you can check out the codebase on my github and the live deployment here.

I'll be back with more next week. Till then, stay consistent!

Top comments (0)