WebSockets with Next.js Part 4: Going Native - Ditching Socket.IO
Welcome to Part 4 of our WebSockets with Next.js series! Throughout Parts 1-3, we built a feature-rich chat application using Socket.IO. It’s been a great learning experience, and Socket.IO served us well with its easy-to-use API and built-in features. But now, it’s time to ask an important question: Do we actually need Socket.IO?
In this part, we’ll explore how to implement WebSockets using the native WebSocket API - no libraries, no abstractions, just pure WebSocket connections. We’ll rebuild our chat application from scratch using native APIs and discover that for many use cases, Socket.IO might be overkill. By the end of this tutorial, you’ll understand when to use Socket.IO and when native WebSockets are the better choice.
1. Why Go Native? Understanding the Trade-offs
Before we dive into code, let’s have an honest conversation about Socket.IO versus native WebSockets.
1.1 The Case Against Socket.IO
Don’t get me wrong - Socket.IO is excellent for many use cases. But it comes with some costs:
- Bundle Size: Socket.IO adds ~60KB (gzipped) to your client bundle. That’s significant for mobile users.
- Custom Protocol: Socket.IO doesn’t use pure WebSockets - it has its own protocol layer on top.
- Server Coupling: Your backend becomes tightly coupled to Socket.IO. No standard WebSocket clients can connect.
- Over-Engineering: Features like rooms, namespaces, and automatic reconnection might be overkill for simple use cases.
- Debugging Complexity: The abstraction layer makes debugging harder when things go wrong.
- Version Lock-In: Client and server versions must match, complicating deployments.
1.2 The Case For Native WebSockets
Native WebSockets offer compelling advantages:
- Zero Dependencies: No library needed - WebSockets are built into browsers and Node.js.
- Standard Protocol: Any WebSocket client can connect - mobile apps, IoT devices, etc.
- Better Performance: No protocol overhead, direct connection.
- Full Control: You decide exactly how connections, reconnections, and messages work.
- Smaller Bundle: Your client-side code is just a few KB.
- Future-Proof: WebSocket standard won’t change, no version conflicts.
1.3 When to Use Each
Use Socket.IO when:
- You need automatic fallback to HTTP long-polling (very rare nowadays)
- You want built-in rooms/namespaces and don’t want to implement them
- Your team is familiar with Socket.IO and values the abstraction
- You’re building a complex multiplayer game with many simultaneous connections
Use Native WebSockets when:
- Building a modern app (2020+) where all browsers support WebSockets
- Performance and bundle size matter
- You want standard WebSocket compatibility
- You’re comfortable implementing reconnection logic
- You don’t need complex features like rooms (or can implement them simply)
For most web applications in 2025, native WebSockets are the better choice.
2. Setting Up Native WebSockets in Next.js
Let’s start fresh with a new Next.js project using native WebSockets.
2.1 Project Setup
We’ll use the same Next.js project structure, but this time we won’t install Socket.IO:
npx create-next-app@latest websocket-nativecd websocket-native
Choose the same options as before (TypeScript, Tailwind, App Router, etc.).
Notice what we’re NOT installing: No socket.io
or socket.io-client
. Just pure Next.js.
2.2 Installing the WebSocket Server
For the server, we’ll use the ws
library - a lightweight, standards-compliant WebSocket implementation:
npm install wsnpm install --save-dev @types/ws
The ws
library is tiny (~30KB uncompressed) and implements the WebSocket protocol spec faithfully.
2.3 Creating the WebSocket Server
Create server.js
in your project root:
const { createServer } = require('http');const { parse } = require('url');const next = require('next');const { WebSocketServer } = require('ws');
const dev = process.env.NODE_ENV !== 'production';const app = next({ dev });const handle = app.getRequestHandler();
app.prepare().then(() => { const server = createServer((req, res) => { const parsedUrl = parse(req.url, true); handle(req, res, parsedUrl); });
// Create WebSocket server const wss = new WebSocketServer({ server, path: '/api/ws' // WebSocket endpoint });
// Store connected clients const clients = new Map();
wss.on('connection', (ws, req) => { console.log('New client connected');
// Generate unique ID for this connection const clientId = Math.random().toString(36).substring(7); clients.set(clientId, ws);
// Send welcome message ws.send(JSON.stringify({ type: 'connection', clientId, message: 'Connected to WebSocket server' }));
// Handle incoming messages ws.on('message', (data) => { try { const message = JSON.parse(data.toString()); console.log('Received:', message);
// Broadcast to all connected clients broadcast(message, clientId); } catch (error) { console.error('Invalid message format:', error); } });
// Handle client disconnect ws.on('close', () => { console.log('Client disconnected:', clientId); clients.delete(clientId);
// Notify others broadcast({ type: 'user_disconnected', clientId }); });
// Handle errors ws.on('error', (error) => { console.error('WebSocket error:', error); }); });
// Broadcast message to all clients except sender function broadcast(message, senderId) { const messageStr = JSON.stringify(message);
clients.forEach((client, id) => { if (client.readyState === ws.OPEN && id !== senderId) { client.send(messageStr); } }); }
const PORT = process.env.PORT || 3000; server.listen(PORT, (err) => { if (err) throw err; console.log(`> Ready on http://localhost:${PORT}`); console.log(`> WebSocket server on ws://localhost:${PORT}/api/ws`); });});
Key differences from Socket.IO:
- No magic - we explicitly manage client connections in a
Map
- Messages are plain JSON strings, not Socket.IO’s custom protocol
- We implement our own broadcast function
- Connection IDs are simple random strings, not Socket.IO’s complex session IDs
2.4 Understanding the Native WebSocket API
The WebSocket API is straightforward:
// Server events (using 'ws' library)ws.on('connection', (socket) => { /* new connection */ });ws.on('message', (data) => { /* received message */ });ws.on('close', () => { /* connection closed */ });ws.on('error', (error) => { /* error occurred */ });
// Sending messagesws.send(JSON.stringify({ type: 'message', data: 'Hello!' }));
// Connection statesws.readyState === WebSocket.OPEN // Ready to send/receivews.readyState === WebSocket.CONNECTING // Still connectingws.readyState === WebSocket.CLOSING // Closing in progressws.readyState === WebSocket.CLOSED // Connection closed
Compare this to Socket.IO’s abstraction:
// Socket.IO waysocket.emit('event-name', data);socket.on('event-name', (data) => {});
// Native WebSocket waysocket.send(JSON.stringify({ type: 'event-name', data }));socket.onmessage = (event) => { const { type, data } = JSON.parse(event.data); if (type === 'event-name') { /* handle */ }};
With native WebSockets, you’re responsible for the message structure. This gives you full control but requires more discipline.
3. Building the Client-Side Connection
Now let’s build the client side using the browser’s native WebSocket API.
3.1 Creating a useWebSocket Hook
Create src/hooks/useWebSocket.ts
:
import { useEffect, useRef, useState, useCallback } from 'react';
interface Message { type: string; data: any; timestamp?: number;}
interface UseWebSocketOptions { url: string; reconnect?: boolean; reconnectInterval?: number; maxReconnectAttempts?: number; onOpen?: () => void; onClose?: () => void; onError?: (error: Event) => void;}
export function useWebSocket(options: UseWebSocketOptions) { const { url, reconnect = true, reconnectInterval = 3000, maxReconnectAttempts = 5, onOpen, onClose, onError } = options;
const [isConnected, setIsConnected] = useState(false); const [messages, setMessages] = useState<Message[]>([]); const wsRef = useRef<WebSocket | null>(null); const reconnectAttemptsRef = useRef(0); const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
// Send message function const sendMessage = useCallback((type: string, data: any) => { if (wsRef.current?.readyState === WebSocket.OPEN) { const message = { type, data, timestamp: Date.now() }; wsRef.current.send(JSON.stringify(message)); return true; } console.warn('WebSocket is not connected'); return false; }, []);
// Connect to WebSocket const connect = useCallback(() => { try { // Close existing connection if any if (wsRef.current) { wsRef.current.close(); }
const ws = new WebSocket(url); wsRef.current = ws;
ws.onopen = () => { console.log('WebSocket connected'); setIsConnected(true); reconnectAttemptsRef.current = 0; onOpen?.(); };
ws.onmessage = (event) => { try { const message = JSON.parse(event.data); setMessages((prev) => [...prev, message]); } catch (error) { console.error('Failed to parse message:', error); } };
ws.onclose = () => { console.log('WebSocket disconnected'); setIsConnected(false); wsRef.current = null; onClose?.();
// Attempt reconnect if (reconnect && reconnectAttemptsRef.current < maxReconnectAttempts) { reconnectAttemptsRef.current++; console.log( `Reconnecting... Attempt ${reconnectAttemptsRef.current}/${maxReconnectAttempts}` );
reconnectTimeoutRef.current = setTimeout(() => { connect(); }, reconnectInterval); } };
ws.onerror = (error) => { console.error('WebSocket error:', error); onError?.(error); }; } catch (error) { console.error('Failed to create WebSocket:', error); } }, [url, reconnect, reconnectInterval, maxReconnectAttempts, onOpen, onClose, onError]);
// Disconnect const disconnect = useCallback(() => { if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); } if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } setIsConnected(false); }, []);
// Connect on mount useEffect(() => { connect();
return () => { disconnect(); }; }, [connect, disconnect]);
return { isConnected, messages, sendMessage, disconnect, reconnect: connect };}
Notice what we implemented ourselves:
- Reconnection logic (Socket.IO does this automatically)
- Connection state tracking
- Message parsing/serialization
- Error handling
This is about 100 lines of code to replace Socket.IO’s client library. For most apps, this is totally manageable.
3.2 Building the Chat Interface
Create src/app/page.tsx
:
'use client';
import { useState, useEffect, useRef } from 'react';import { useWebSocket } from '../hooks/useWebSocket';
interface ChatMessage { id: string; user: string; text: string; timestamp: number;}
export default function Chat() { const [username, setUsername] = useState(''); const [isJoined, setIsJoined] = useState(false); const [messageInput, setMessageInput] = useState(''); const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]); const messagesEndRef = useRef<HTMLDivElement>(null);
// Get WebSocket URL (client-side only) const wsUrl = typeof window !== 'undefined' ? `ws://${window.location.host}/api/ws` : '';
const { isConnected, messages, sendMessage } = useWebSocket({ url: wsUrl, reconnect: true, onOpen: () => console.log('Connected!'), onClose: () => console.log('Disconnected!'), });
// Handle incoming messages useEffect(() => { messages.forEach((msg) => { if (msg.type === 'chat') { setChatMessages((prev) => [...prev, msg.data as ChatMessage]); } }); }, [messages]);
// Auto-scroll to bottom useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [chatMessages]);
const handleJoin = () => { if (username.trim()) { setIsJoined(true); sendMessage('user_joined', { username }); } };
const handleSendMessage = (e: React.FormEvent) => { e.preventDefault();
if (!messageInput.trim()) return;
const message: ChatMessage = { id: Math.random().toString(36).substring(7), user: username, text: messageInput, timestamp: Date.now() };
sendMessage('chat', message); setChatMessages((prev) => [...prev, message]); // Optimistic update setMessageInput(''); };
if (!isJoined) { return ( <div className="flex items-center justify-center min-h-screen bg-gray-100"> <div className="bg-white p-8 rounded-lg shadow-md w-96"> <h2 className="text-2xl font-bold mb-4">Join Chat</h2> <input type="text" placeholder="Enter your username" value={username} onChange={(e) => setUsername(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && handleJoin()} className="w-full px-4 py-2 border rounded mb-4" /> <button onClick={handleJoin} className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600" > Join </button> <div className="mt-4 text-sm text-gray-600"> Status: {isConnected ? '🟢 Connected' : '🔴 Disconnected'} </div> </div> </div> ); }
return ( <div className="flex flex-col h-screen bg-gray-100"> <div className="bg-white shadow-md p-4 flex justify-between items-center"> <h1 className="text-xl font-bold">Native WebSocket Chat</h1> <div className="text-sm"> {isConnected ? '🟢 Connected' : '🔴 Disconnected'} </div> </div>
<div className="flex-1 overflow-y-auto p-4 space-y-2"> {chatMessages.map((msg) => ( <div key={msg.id} className={`p-3 rounded-lg ${ msg.user === username ? 'bg-blue-500 text-white ml-auto max-w-md' : 'bg-white max-w-md' }`} > <div className="font-semibold text-sm">{msg.user}</div> <div>{msg.text}</div> <div className="text-xs opacity-70 mt-1"> {new Date(msg.timestamp).toLocaleTimeString()} </div> </div> ))} <div ref={messagesEndRef} /> </div>
<form onSubmit={handleSendMessage} className="bg-white p-4 shadow-md"> <div className="flex gap-2"> <input type="text" value={messageInput} onChange={(e) => setMessageInput(e.target.value)} placeholder="Type a message..." className="flex-1 px-4 py-2 border rounded" /> <button type="submit" disabled={!isConnected} className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-300" > Send </button> </div> </form> </div> );}
3.3 What We Gained (and Lost)
What we gained:
- ✅ No external dependencies on the client
- ✅ Smaller bundle size (~50KB lighter)
- ✅ Full control over message format
- ✅ Standard WebSocket protocol
- ✅ Simpler mental model
What we lost from Socket.IO:
- ❌ Automatic reconnection (but we implemented it!)
- ❌ Rooms and namespaces (can implement if needed)
- ❌ Binary data support (WebSockets support this natively)
- ❌ Broadcasting helper methods (we wrote our own)
The trade-off is clear: a bit more code, but much more control and better performance.
4. Implementing Advanced Features
Let’s add some features that Socket.IO provides out of the box.
4.1 Adding Rooms Support
Update server.js
to support chat rooms:
// Store roomsconst rooms = new Map(); // roomId -> Set of client IDs
wss.on('connection', (ws, req) => { const clientId = Math.random().toString(36).substring(7); clients.set(clientId, { socket: ws, rooms: new Set() });
ws.on('message', (data) => { const message = JSON.parse(data.toString());
switch (message.type) { case 'join_room': joinRoom(clientId, message.data.roomId); break;
case 'leave_room': leaveRoom(clientId, message.data.roomId); break;
case 'room_message': broadcastToRoom(message.data.roomId, message, clientId); break;
default: broadcast(message, clientId); } });
ws.on('close', () => { // Remove from all rooms const client = clients.get(clientId); client?.rooms.forEach(roomId => { leaveRoom(clientId, roomId); }); clients.delete(clientId); });});
function joinRoom(clientId, roomId) { if (!rooms.has(roomId)) { rooms.set(roomId, new Set()); } rooms.get(roomId).add(clientId); clients.get(clientId).rooms.add(roomId);
console.log(`Client ${clientId} joined room ${roomId}`);}
function leaveRoom(clientId, roomId) { rooms.get(roomId)?.delete(clientId); clients.get(clientId)?.rooms.delete(roomId);
// Clean up empty rooms if (rooms.get(roomId)?.size === 0) { rooms.delete(roomId); }}
function broadcastToRoom(roomId, message, senderId) { const roomClients = rooms.get(roomId); if (!roomClients) return;
const messageStr = JSON.stringify(message);
roomClients.forEach(clientId => { if (clientId !== senderId) { const client = clients.get(clientId); if (client?.socket.readyState === WebSocket.OPEN) { client.socket.send(messageStr); } } });}
We just implemented Socket.IO rooms in ~40 lines of code!
4.2 Adding Heartbeat/Ping-Pong
Keep connections alive with periodic pings:
// Add to server.jsconst HEARTBEAT_INTERVAL = 30000; // 30 seconds
wss.on('connection', (ws) => { ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
// ... rest of connection handling});
// Set up heartbeat intervalconst heartbeat = setInterval(() => { wss.clients.forEach((ws) => { if (ws.isAlive === false) { console.log('Terminating inactive connection'); return ws.terminate(); }
ws.isAlive = false; ws.ping(); });}, HEARTBEAT_INTERVAL);
// Clean up interval on graceful shutdown (see section 6.3)// The heartbeat interval will run continuously while the server is up
This prevents connections from timing out and detects dead connections.
5. Performance Comparison
Let’s look at real-world performance differences.
5.1 Bundle Size
# Socket.IO Clientsocket.io-client: ~61KB (gzipped)
# Native WebSocket (our hook)useWebSocket.ts: ~2KB (gzipped)
# Savings: ~59KB = ~97% smaller!
For users on slow connections, this matters. A lot.
5.2 Message Overhead
Socket.IO message:
42["chat message",{"id":"abc123","text":"Hello"}]
Native WebSocket message:
{"type":"chat","data":{"id":"abc123","text":"Hello"}}
Socket.IO’s protocol adds packet types and escaping. Native WebSocket is pure JSON.
5.3 Connection Time
In my testing:
- Socket.IO: ~150-200ms to establish connection
- Native WebSocket: ~50-80ms to establish connection
Native WebSockets are 2-3x faster to connect.
6. Best Practices for Native WebSockets
Based on building production WebSocket apps, here are my top recommendations:
6.1 Message Schema Validation
Use TypeScript interfaces and runtime validation:
import { z } from 'zod';
const ChatMessageSchema = z.object({ type: z.literal('chat'), data: z.object({ id: z.string(), user: z.string(), text: z.string().max(1000), timestamp: z.number() })});
// In your message handlerws.on('message', (data) => { try { const parsed = JSON.parse(data.toString()); const validated = ChatMessageSchema.parse(parsed); // Now TypeScript knows the exact shape handleChatMessage(validated); } catch (error) { console.error('Invalid message:', error); ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format' })); }});
6.2 Rate Limiting
Prevent abuse with message rate limiting:
const MESSAGE_LIMIT = 10; // messagesconst TIME_WINDOW = 1000; // per second
const messageCounts = new Map(); // clientId -> count
function checkRateLimit(clientId) { const now = Date.now(); const record = messageCounts.get(clientId) || { count: 0, resetTime: now + TIME_WINDOW };
if (now > record.resetTime) { record.count = 0; record.resetTime = now + TIME_WINDOW; }
record.count++; messageCounts.set(clientId, record);
return record.count <= MESSAGE_LIMIT;}
ws.on('message', (data) => { if (!checkRateLimit(clientId)) { ws.send(JSON.stringify({ type: 'error', message: 'Rate limit exceeded' })); return; } // Process message});
6.3 Graceful Shutdown
Handle server restarts properly:
process.on('SIGTERM', () => { console.log('SIGTERM received, closing WebSocket server...');
// Notify all clients wss.clients.forEach((ws) => { ws.send(JSON.stringify({ type: 'server_shutdown', message: 'Server restarting, reconnecting...' })); ws.close(1001, 'Server restarting'); });
// Close server and clean up heartbeat clearInterval(heartbeat); // Clean up the heartbeat interval wss.close(() => { console.log('WebSocket server closed'); process.exit(0); });});
6.4 Authentication
Authenticate connections using query parameters or initial messages:
// Client sends auth token on connectws.send(JSON.stringify({ type: 'auth', token: 'user-jwt-token'}));
// Server validatesws.on('message', (data) => { const message = JSON.parse(data.toString());
if (message.type === 'auth') { const user = validateToken(message.token); if (user) { clients.get(clientId).user = user; clients.get(clientId).authenticated = true; ws.send(JSON.stringify({ type: 'auth_success' })); } else { ws.close(1008, 'Authentication failed'); } return; }
// Reject unauthenticated messages if (!clients.get(clientId)?.authenticated) { ws.close(1008, 'Not authenticated'); return; }
// Process authenticated message});
7. When Socket.IO Still Wins
To be fair, there are scenarios where Socket.IO is still the better choice:
- Complex Broadcasting Patterns: If you need sophisticated room/namespace hierarchies
- Browser Compatibility: If you must support very old browsers (pre-2012)
- Transport Fallback: In restrictive corporate networks that block WebSocket
- Mixed Binary/JSON Payloads: Socket.IO’s parser makes it easier to handle mixed message types - native WebSockets support binary (ArrayBuffer, Blob) perfectly well, but you’ll need to implement your own protocol to mix binary and JSON messages
- Team Familiarity: If your team knows Socket.IO well and isn’t comfortable with lower-level APIs
But for most modern web applications in 2025, native WebSockets are sufficient and superior.
8. Conclusion
In this part, we’ve rebuilt our chat application using native WebSockets. We learned:
- Socket.IO is often overkill - For many use cases, native WebSockets are simpler and more performant
- Bundle size matters - Saving 60KB helps users on slow connections
- You have control - Implementing reconnection, rooms, and heartbeats isn’t hard
- Standards are good - Native WebSockets are future-proof and interoperable
- Know the trade-offs - Socket.IO still has its place for complex scenarios
My recommendation: Start with native WebSockets. Only reach for Socket.IO if you have specific needs that justify the added complexity and bundle size.
Throughout this four-part series, we’ve gone from Socket.IO basics to advanced features, and finally to native WebSocket implementations. You now have the knowledge to choose the right tool for your specific use case.
What’s Next?
Want to go deeper? Consider exploring:
- Server-Sent Events (SSE) - For one-way server-to-client updates
- WebRTC - For peer-to-peer video/audio/data
- GraphQL Subscriptions - For real-time with GraphQL
- tRPC Subscriptions - For type-safe WebSocket with tRPC
- WebSocket Scaling - Using Redis pub/sub for multi-server setups
Happy coding, and may your WebSockets stay connected! 🚀
Have questions or found this helpful? Let me know in the comments or reach out on Twitter!
Enjoyed this article? Subscribe for more!
Related Articles

WebSockets with Next.js Part 1: Basics and Setup
Learn about WebSockets with Next.js Part 1: Basics and Setup

WebSockets with Next.js Part 3: Advanced Concepts and Best Practices
Learn about WebSockets with Next.js Part 3: Advanced Concepts and Best Practices

WebSockets with Next.js Part 2: Real-Time Chat
Learn about WebSockets with Next.js Part 2: Real-Time Chat

From Idea to MVP: Building FastForward IQ with Next.js
Learn about From Idea to MVP: Building FastForward IQ with Next.js