Building Real-Time Full-Stack Applications: A Complete Guide
Ever wondered how apps like Notion, Linear, or Discord keep everything in sync across thousands of users? Here's how real-time works under the hood — and how you can build it yourself.
TL;DR
- • Real-time apps sync data instantly across multiple users without page refreshes
- • Three main approaches: WebSockets (bidirectional), SSE (server push), Polling (repeated requests)
- • Use WebSockets for chat/collaboration, SSE for live feeds, Polling for simple updates
- • Supabase Realtime offers the easiest path with built-in auth, scaling, and reconnection logic
- • Always handle connection failures, implement exponential backoff, and secure with proper auth
Ever opened a Google Doc and watched your teammate's cursor move in real-time? Or joined a Slack channel and saw messages appear instantly without hitting refresh?
That's real-time magic. And here's the thing — most developers don't know it's actually not that complicated to build.
In this guide, I'll show you exactly how companies like Notion, Linear, and Discord keep thousands of users in sync, and how you can implement the same patterns in your own applications.
1. What Are Real-Time Applications and Why They Matter
A real-time application is one where data updates appear to users instantly without requiring a manual refresh.
Think of it like the difference between sending letters (traditional HTTP) and having a phone call (real-time). With letters, you send a request and wait for a response. With a phone call, information flows continuously in both directions.
Why Real-Time Matters in 2025
- User Expectations: Users expect instant feedback. A 2-second delay feels like an eternity.
- Collaboration: Remote work demands real-time collaboration tools like Figma, Miro, and Notion.
- Competitive Advantage: Real-time features differentiate your app from competitors still using "refresh to see updates."
- Better UX: Live notifications, presence indicators, and instant updates create engaging experiences.
Real-World Examples
- • Chat apps: Slack, Discord, WhatsApp Web
- • Collaborative editing: Google Docs, Figma, Notion
- • Live dashboards: Analytics, trading platforms, monitoring tools
- • Gaming: Multiplayer games, leaderboards
- • Social feeds: Twitter/X live updates, Instagram stories
2. The 3 Approaches to Real-Time Communication
There are three main ways to implement real-time features. Each has distinct trade-offs, and choosing the right one depends on your use case.
① HTTP Polling (Long Polling)
The client repeatedly asks the server "got anything new?" at regular intervals. The server holds the connection open until new data arrives or a timeout occurs.
Pros
- • Simple to implement
- • Works with any HTTP infrastructure
- • No special server requirements
- • Compatible with legacy systems
Cons
- • High overhead from repeated connections
- • Increased latency
- • Resource-intensive at scale
- • Inefficient bandwidth usage
// Client-side Long Polling
async function poll() {
try {
const response = await fetch('/api/poll');
const data = await response.json();
// Update UI with new data
handleUpdate(data);
// Immediately reconnect
poll();
} catch (error) {
console.error('Polling error:', error);
// Retry after 5 seconds on error
setTimeout(poll, 5000);
}
}
// Start polling
poll();Best for: Simple notifications, infrequent updates, legacy systems, or when other methods aren't available.
② Server-Sent Events (SSE)
The server pushes updates to the client over a single, persistent HTTP connection. Built into browsers via the EventSource API.
Pros
- • Automatic reconnection built-in
- • Simple API and implementation
- • Efficient for server-to-client updates
- • Works over standard HTTP
Cons
- • One-way communication only
- • Limited to UTF-8 text data
- • Browser connection limits (6 per domain)
- • No native binary data support
// Client-side SSE
const eventSource = new EventSource('/api/events');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
handleUpdate(data);
};
eventSource.onerror = (error) => {
console.log('Connection lost, auto-reconnecting...');
// EventSource automatically reconnects
};
// Named events
eventSource.addEventListener('user-joined', (event) => {
const user = JSON.parse(event.data);
showNotification(`${user.name} joined the channel`);
});
// Close connection when done
// eventSource.close();// Server-side SSE (Next.js Route Handler)
export async function GET(request: Request) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// Send updates to client
const sendUpdate = (data: any) => {
const message = `data: ${JSON.stringify(data)}\n\n`;
controller.enqueue(encoder.encode(message));
};
// Send initial connection message
sendUpdate({ type: 'connected', timestamp: Date.now() });
// Send periodic updates
const interval = setInterval(() => {
sendUpdate({
type: 'update',
value: Math.random()
});
}, 1000);
// Cleanup on close
request.signal.addEventListener('abort', () => {
clearInterval(interval);
controller.close();
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}Best for: Live feeds, progress updates, server notifications, real-time dashboards, live news/sports scores.
③ WebSockets
A persistent, bidirectional connection between client and server. After an initial HTTP handshake, the connection upgrades to the WebSocket protocol.
Pros
- • True bidirectional communication
- • Low latency and overhead
- • Supports binary data
- • Perfect for interactive apps
Cons
- • More complex implementation
- • Requires manual reconnection logic
- • Some proxies/firewalls may block
- • Stateful connections complicate scaling
// Client-side WebSocket with reconnection
let ws: WebSocket;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
function connect() {
ws = new WebSocket('wss://example.com/socket');
ws.onopen = () => {
console.log('Connected to WebSocket');
reconnectAttempts = 0;
// Subscribe to channels
ws.send(JSON.stringify({
type: 'subscribe',
channel: 'chat-room-1'
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleUpdate(data);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('Connection closed');
// Exponential backoff reconnection
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
setTimeout(() => {
reconnectAttempts++;
connect();
}, delay);
}
};
}
// Send message
function sendMessage(message: string) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'message',
content: message
}));
}
}
connect();Best for: Chat applications, collaborative editing, multiplayer games, trading platforms, IoT control panels.
3. When to Use Each Approach: Decision Tree
┌─ Do you need bidirectional communication?
│ (Both client and server need to send data)
│
├─ YES → Use WebSockets
│ ├─ Chat applications
│ ├─ Collaborative editing
│ ├─ Multiplayer games
│ └─ Real-time collaboration
│
└─ NO → Is data pushed only from server to client?
│
├─ YES → Use Server-Sent Events (SSE)
│ ├─ Live feeds
│ ├─ Notifications
│ ├─ Progress updates
│ └─ Dashboard metrics
│
└─ NO → Are updates infrequent (<1 per minute)?
│
├─ YES → Use HTTP Polling
│ ├─ Simple status checks
│ ├─ Email notifications
│ └─ Legacy system integration
│
└─ NO → Consider WebSockets or SSE
based on complexity requirements💡 Pro Tip
Start with Server-Sent Events if you're unsure. It's simpler than WebSockets but more efficient than polling. You can always upgrade to WebSockets later if you need bidirectional communication.
4. WebSocket Implementation with Socket.io
While the native WebSocket API works, Socket.io provides automatic reconnection, fallback to polling, and room/namespace management out of the box.
Installation
npm install socket.io socket.io-clientServer Implementation
// server.ts (Node.js + Express)
import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_URL,
credentials: true,
},
});
// Middleware for authentication
io.use((socket, next) => {
const token = socket.handshake.auth.token;
try {
const user = verifyJWT(token);
socket.data.user = user;
next();
} catch (error) {
next(new Error('Authentication failed'));
}
});
// Connection handler
io.on('connection', (socket) => {
console.log(`User connected: ${socket.data.user.id}`);
// Join a room
socket.on('join-room', (roomId) => {
socket.join(roomId);
// Notify others in the room
socket.to(roomId).emit('user-joined', {
userId: socket.data.user.id,
userName: socket.data.user.name,
});
});
// Handle messages
socket.on('send-message', (data) => {
const { roomId, message } = data;
// Broadcast to room (excluding sender)
socket.to(roomId).emit('new-message', {
userId: socket.data.user.id,
userName: socket.data.user.name,
message,
timestamp: Date.now(),
});
});
// Handle typing indicators
socket.on('typing-start', (roomId) => {
socket.to(roomId).emit('user-typing', {
userId: socket.data.user.id,
});
});
socket.on('typing-stop', (roomId) => {
socket.to(roomId).emit('user-stopped-typing', {
userId: socket.data.user.id,
});
});
// Handle disconnection
socket.on('disconnect', () => {
console.log(`User disconnected: ${socket.data.user.id}`);
});
});
httpServer.listen(3001, () => {
console.log('Socket.io server running on port 3001');
});Client Implementation (React)
// hooks/useSocket.ts
import { useEffect, useState, useRef } from 'react';
import { io, Socket } from 'socket.io-client';
export function useSocket(roomId: string) {
const [connected, setConnected] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const socketRef = useRef<Socket | null>(null);
useEffect(() => {
// Get auth token from your auth system
const token = localStorage.getItem('auth-token');
// Connect to Socket.io server
socketRef.current = io('http://localhost:3001', {
auth: { token },
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
const socket = socketRef.current;
socket.on('connect', () => {
setConnected(true);
socket.emit('join-room', roomId);
});
socket.on('disconnect', () => {
setConnected(false);
});
socket.on('new-message', (message: Message) => {
setMessages((prev) => [...prev, message]);
});
socket.on('user-joined', (data) => {
console.log(`${data.userName} joined the room`);
});
return () => {
socket.disconnect();
};
}, [roomId]);
const sendMessage = (message: string) => {
if (socketRef.current?.connected) {
socketRef.current.emit('send-message', {
roomId,
message,
});
}
};
const startTyping = () => {
socketRef.current?.emit('typing-start', roomId);
};
const stopTyping = () => {
socketRef.current?.emit('typing-stop', roomId);
};
return {
connected,
messages,
sendMessage,
startTyping,
stopTyping,
};
}6. Supabase Realtime: The Modern Approach
Supabase Realtime is my go-to for production real-time apps. It handles the hard parts — authentication, scaling, and reconnection — so you can focus on building features.
Why Supabase Realtime Wins
Implementation Example
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);// hooks/useRealtimeMessages.ts
import { useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase';
import type { RealtimeChannel } from '@supabase/supabase-js';
export function useRealtimeMessages(roomId: string) {
const [messages, setMessages] = useState<Message[]>([]);
const [channel, setChannel] = useState<RealtimeChannel | null>(null);
useEffect(() => {
// Create channel
const roomChannel = supabase.channel(`room:${roomId}`);
// Subscribe to database changes
roomChannel
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`,
},
(payload) => {
setMessages((prev) => [...prev, payload.new as Message]);
}
)
// Subscribe to broadcast messages
.on('broadcast', { event: 'new-message' }, (payload) => {
console.log('Broadcast message:', payload);
})
// Track presence (who's online)
.on('presence', { event: 'sync' }, () => {
const state = roomChannel.presenceState();
console.log('Online users:', state);
})
.subscribe((status) => {
if (status === 'SUBSCRIBED') {
// Track your presence
roomChannel.track({
user_id: 'user-123',
online_at: new Date().toISOString(),
});
}
});
setChannel(roomChannel);
return () => {
roomChannel.unsubscribe();
};
}, [roomId]);
const sendMessage = async (content: string) => {
// Insert into database (triggers real-time update)
const { error } = await supabase
.from('messages')
.insert({
room_id: roomId,
content,
user_id: 'user-123',
});
if (error) console.error('Error sending message:', error);
};
const broadcast = (event: string, payload: any) => {
channel?.send({
type: 'broadcast',
event,
payload,
});
};
return { messages, sendMessage, broadcast };
}// components/ChatRoom.tsx
'use client';
import { useState } from 'react';
import { useRealtimeMessages } from '@/hooks/useRealtimeMessages';
export function ChatRoom({ roomId }: { roomId: string }) {
const [input, setInput] = useState('');
const { messages, sendMessage, broadcast } = useRealtimeMessages(roomId);
const handleSend = async () => {
if (!input.trim()) return;
await sendMessage(input);
setInput('');
};
const handleTyping = () => {
broadcast('typing', { user_id: 'user-123' });
};
return (
<div className="flex flex-col h-screen">
<div className="flex-1 overflow-y-auto p-4 space-y-2">
{messages.map((msg) => (
<div key={msg.id} className="p-3 bg-muted rounded-lg">
<p className="font-semibold">{msg.user_id}</p>
<p>{msg.content}</p>
</div>
))}
</div>
<div className="p-4 border-t">
<div className="flex gap-2">
<input
value={input}
onChange={(e) => {
setInput(e.target.value);
handleTyping();
}}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="Type a message..."
className="flex-1 px-4 py-2 rounded-lg border"
/>
<button
onClick={handleSend}
className="px-6 py-2 bg-primary text-white rounded-lg"
>
Send
</button>
</div>
</div>
</div>
);
}💡 Pro Tip: Secure Your Channels
Always use Row Level Security (RLS) policies to protect your real-time data. Set up policies in Supabase to ensure users can only access messages they're authorized to see.
7. Frontend Patterns: React Hooks and State Management
Managing real-time state in React requires careful handling of subscriptions, updates, and cleanup.
Custom Hook Pattern
// hooks/useRealTimeData.ts
import { useEffect, useState, useRef } from 'react';
export function useRealTimeData<T>(
subscribe: (callback: (data: T) => void) => () => void
) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
setIsConnected(true);
const unsubscribe = subscribe((newData) => {
setData(newData);
setError(null);
});
return () => {
setIsConnected(false);
unsubscribe();
};
}, [subscribe]);
return { data, error, isConnected };
}
// Usage
function ChatMessages({ roomId }: { roomId: string }) {
const { data: messages, isConnected } = useRealTimeData((callback) => {
const channel = subscribeToRoom(roomId, callback);
return () => channel.unsubscribe();
});
return (
<div>
{!isConnected && <div>Connecting...</div>}
{messages?.map((msg) => <Message key={msg.id} {...msg} />)}
</div>
);
}Optimistic Updates Pattern
// Optimistic updates for better UX
function useChatMessages(roomId: string) {
const [messages, setMessages] = useState<Message[]>([]);
const sendMessage = async (content: string) => {
// Create optimistic message
const optimisticMessage: Message = {
id: `temp-${Date.now()}`,
content,
user_id: currentUserId,
created_at: new Date().toISOString(),
status: 'sending', // Track sending state
};
// Add optimistically
setMessages((prev) => [...prev, optimisticMessage]);
try {
// Send to server
const { data } = await supabase
.from('messages')
.insert({ room_id: roomId, content })
.select()
.single();
// Replace optimistic message with real one
setMessages((prev) =>
prev.map((msg) =>
msg.id === optimisticMessage.id
? { ...data, status: 'sent' }
: msg
)
);
} catch (error) {
// Mark as failed
setMessages((prev) =>
prev.map((msg) =>
msg.id === optimisticMessage.id
? { ...msg, status: 'failed' }
: msg
)
);
}
};
return { messages, sendMessage };
}9. Connection Failures and Reconnection Strategies
⚠️ Common Mistake
Most developers forget to handle reconnection properly. Users lose their place in the conversation, miss messages, or see duplicate data when the connection drops.
Exponential Backoff Implementation
// Robust reconnection with exponential backoff
class ReconnectingWebSocket {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectInterval = 1000; // Start at 1 second
private maxReconnectInterval = 30000; // Max 30 seconds
constructor(private url: string) {
this.connect();
}
private connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected');
this.reconnectAttempts = 0;
this.reconnectInterval = 1000;
};
this.ws.onclose = () => {
this.handleReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
private handleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
return;
}
this.reconnectAttempts++;
// Exponential backoff with jitter
const jitter = Math.random() * 1000;
const delay = Math.min(
this.reconnectInterval * Math.pow(2, this.reconnectAttempts) + jitter,
this.maxReconnectInterval
);
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
setTimeout(() => {
this.connect();
}, delay);
}
send(data: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
} else {
console.warn('WebSocket not connected, queuing message');
// Implement message queue here
}
}
close() {
this.maxReconnectAttempts = 0; // Prevent reconnection
this.ws?.close();
}
}Message Queue for Offline Support
// Queue messages when offline, send when reconnected
class OfflineQueueWebSocket extends ReconnectingWebSocket {
private messageQueue: any[] = [];
send(data: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
// Send queued messages first
while (this.messageQueue.length > 0) {
const queuedMessage = this.messageQueue.shift();
this.ws.send(JSON.stringify(queuedMessage));
}
// Send current message
this.ws.send(JSON.stringify(data));
} else {
// Queue for later
this.messageQueue.push(data);
console.log(`Message queued (${this.messageQueue.length} total)`);
}
}
}10. Security Considerations
Real-time connections introduce unique security challenges. Here's how to protect your application.
Essential Security Measures
// Secure WebSocket authentication
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error('Authentication token required'));
}
// Verify JWT
const payload = await verifyToken(token);
// Attach user to socket
socket.data.userId = payload.sub;
socket.data.userRole = payload.role;
next();
} catch (error) {
next(new Error('Invalid authentication token'));
}
});
// Authorize room access
socket.on('join-room', async (roomId) => {
const hasAccess = await checkRoomAccess(
socket.data.userId,
roomId
);
if (!hasAccess) {
socket.emit('error', { message: 'Access denied' });
return;
}
socket.join(roomId);
});11. Performance Optimization
Reduce Bandwidth
- • Send only changed data (deltas, not full objects)
- • Compress messages with gzip or Brotli
- • Use binary protocols for large data
- • Debounce rapid updates (typing indicators)
Reduce Latency
- • Use CDN/edge locations near users
- • Keep payloads small (<1KB ideal)
- • Batch related updates together
- • Use connection pooling
// Debounce typing indicators to reduce messages
import { useEffect, useRef } from 'react';
function useTypingIndicator(onTyping: () => void, delay = 1000) {
const timeoutRef = useRef<NodeJS.Timeout>();
const isTypingRef = useRef(false);
const handleTyping = () => {
// Send "started typing" only once
if (!isTypingRef.current) {
onTyping();
isTypingRef.current = true;
}
// Reset timeout
clearTimeout(timeoutRef.current);
// Send "stopped typing" after delay
timeoutRef.current = setTimeout(() => {
isTypingRef.current = false;
onTyping(); // or onStoppedTyping()
}, delay);
};
useEffect(() => {
return () => clearTimeout(timeoutRef.current);
}, []);
return handleTyping;
}13. Common Pitfalls and How to Avoid Them
❌ Memory Leaks from Subscriptions
Forgetting to unsubscribe causes memory leaks and duplicate messages.
// ❌ BAD: No cleanup
useEffect(() => {
socket.on('message', handleMessage);
}, []);
// ✅ GOOD: Clean up subscriptions
useEffect(() => {
socket.on('message', handleMessage);
return () => {
socket.off('message', handleMessage);
};
}, []);❌ Not Handling Reconnection
Connections will drop. Plan for it with automatic reconnection and state recovery.
// ✅ GOOD: Handle reconnection
socket.on('disconnect', () => {
// Show "Reconnecting..." UI
setConnectionStatus('reconnecting');
// Fetch missed messages after reconnect
socket.once('connect', async () => {
const missedMessages = await fetchMessagesSince(lastMessageTime);
setMessages((prev) => [...prev, ...missedMessages]);
setConnectionStatus('connected');
});
});❌ Sending Too Many Updates
Cursor positions, scroll positions, and typing indicators should be throttled or debounced.
Wrap Up
Real-time features are no longer a luxury — they're an expectation. Whether you're building the next collaborative doc editor or a simple notification system, understanding these patterns will save you weeks of trial and error.
Quick Recap
- • WebSockets for bidirectional chat, collaboration, and games
- • SSE for simple live feeds, notifications, and dashboards
- • Polling for infrequent updates and legacy systems
- • Supabase Realtime for production apps with minimal setup
- • Always implement reconnection logic with exponential backoff
- • Secure connections with JWT authentication and RLS
- • Optimize bandwidth by sending deltas and debouncing updates
💬 Discussion Time
Which real-time approach have you used in production? What challenges did you face? Drop a comment and let's learn from each other's experiences!
Bonus question: Have you tried Supabase Realtime? How does it compare to your current setup?
Continue Reading
Explore more articles on software engineering and technology
How to Make Best Use of Cursor AI for Development
Discover how Cursor AI is revolutionizing software development with intelligent code completion and AI-powered refactoring.
Building Agentic Workflows for Restaurant Discovery
Learn how to create intelligent AI agents that help users discover nearby restaurants based on specific queries.
How to Leverage v0 to Build Your Professional Portfolio
Discover how v0's AI-powered development platform can help you create a stunning portfolio website in minutes.