Real-Time Features
WebSockets, Server-Sent Events, and real-time data patterns
Polling vs SSE vs WebSocket
SSE is often the right default for Next.js apps. It uses plain HTTP, works with serverless functions, and handles one-direction streaming without WebSocket infrastructure overhead. Reach for WebSocket when the client needs to send data back over the same connection.
SSE from a Route Handler
Next.js Route Handlers can stream responses — perfect for Server-Sent Events.
// app/api/notifications/stream/route.ts
import { NextRequest } from "next/server"
export async function GET(request: NextRequest) {
const stream = new ReadableStream({
start(controller) {
// Send a message every 3 seconds
const interval = setInterval(() => {
const data = JSON.stringify({
id: crypto.randomUUID(),
message: `Update at ${new Date().toISOString()}`,
type: Math.random() > 0.7 ? "alert" : "info",
})
controller.enqueue(new TextEncoder().encode(`data: ${data}\n\n`))
}, 3000)
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",
},
})
}
Client consumption:
"use client"
import { useEffect, useState } from "react"
export function NotificationFeed() {
const [notifications, setNotifications] = useState<string[]>([])
useEffect(() => {
const eventSource = new EventSource("/api/notifications/stream")
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data)
setNotifications((prev) => [data.message, ...prev].slice(0, 50))
}
eventSource.onerror = () => {
console.error("SSE connection error — retrying...")
// EventSource auto-reconnects
}
return () => eventSource.close()
}, [])
return (
<ul className="space-y-2">
{notifications.map((msg, i) => (
<li key={i} className="rounded bg-gray-100 p-2 text-sm">
{msg}
</li>
))}
</ul>
)
}
WebSocket with Socket.IO
For bidirectional communication, socket.io is the most ergonomic option.
// server.ts — custom Next.js server
import { createServer } from "http"
import { parse } from "url"
import next from "next"
import { Server } from "socket.io"
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)
})
const io = new Server(server)
io.on("connection", (socket) => {
console.log("Client connected:", socket.id)
socket.on("message", (data) => {
// Broadcast to all connected clients
io.emit("message", { userId: socket.id, text: data })
})
socket.on("disconnect", () => {
console.log("Client disconnected:", socket.id)
})
})
server.listen(3000)
})
"use client"
import { useEffect, useState } from "react"
import { io, Socket } from "socket.io-client"
export function ChatRoom() {
const [socket, setSocket] = useState<Socket | null>(null)
const [messages, setMessages] = useState<string[]>([])
useEffect(() => {
const s = io()
setSocket(s)
s.on("message", (data: { userId: string; text: string }) => {
setMessages((prev) => [...prev, `${data.userId}: ${data.text}`])
})
return () => {
s.close()
}
}, [])
function send(text: string) {
socket?.emit("message", text)
}
return (
<div>
<MessageList messages={messages} />
<MessageInput onSend={send} />
</div>
)
}
Socket.IO requires a persistent Node.js server. It won't run on Vercel's serverless functions. For serverless-friendly WebSocket, use a third-party provider like Pusher, Ably, or Supabase Realtime which manage the persistent connection infrastructure.
Optimistic Updates
Optimistic updates update the UI before the server responds — making the app feel instant.
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
interface Todo {
id: string
text: string
completed: boolean
}
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [todos, setTodos] = useState(initialTodos)
const router = useRouter()
async function toggle(todo: Todo) {
// Optimistic update — flip immediately
setTodos((prev) =>
prev.map((t) =>
t.id === todo.id ? { ...t, completed: !t.completed } : t
)
)
try {
const res = await fetch(`/api/todos/${todo.id}`, {
method: "PATCH",
body: JSON.stringify({ completed: !todo.completed }),
})
if (!res.ok) throw new Error("Failed to update")
router.refresh() // Server re-render to confirm state
} catch {
// Rollback on failure
setTodos((prev) =>
prev.map((t) =>
t.id === todo.id ? { ...t, completed: todo.completed } : t
)
)
}
}
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<label className={todo.completed ? "line-through" : ""}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggle(todo)}
/>
{todo.text}
</label>
</li>
))}
</ul>
)
}
When Real-Time Is Actually Needed
| Scenario | Approach | Why |
|---|---|---|
| AI response streaming | SSE | Server pushes tokens as they're generated — one direction only |
| Live dashboard metrics | Polling (30s) | Data changes infrequently; extra infrastructure not worth it |
| Chat application | WebSocket | Users send and receive in both directions; low latency needed |
| Notification badge | SSE | Server pushes when new notification arrives; client just displays count |
| Collaborative cursor | WebSocket | Both users send and receive position data continuously |
| User is typing indicator | WebSocket | Need near-instant bidirectional signaling |
Start with polling. If your polling interval is under 10 seconds and you have more than 1,000 concurrent users, switch to SSE. Upgrade to WebSocket only when the client needs to send data back. Premature optimization toward real-time adds significant complexity.
See It In Action
1"use client"2import { useEffect, useState } from "react"3 4interface Notification {5id: string6message: string7timestamp: string8read: boolean9}10 11export default function NotificationBell() {12const [notifications, setNotifications] = useState<Notification[]>([])13const [connected, setConnected] = useState(false)14const [unreadCount, setUnreadCount] = useState(0)15 16useEffect(() => {17const eventSource = new EventSource("/api/notifications/stream")18 19 eventSource.onopen = () => setConnected(true)20 21 eventSource.onmessage = (event) => {22 const data = JSON.parse(event.data)23 const notification: Notification = {24 id: data.id || crypto.randomUUID(),25 message: data.message,26 timestamp: new Date().toISOString(),27 read: false,28 }29 setNotifications((prev) => [notification, ...prev].slice(0, 20))30 setUnreadCount((prev) => prev + 1)31 }32 33 eventSource.onerror = () => {34 setConnected(false)35 }36 37 return () => {38 eventSource.close()39 }40 41}, [])42 43function markAsRead(id: string) {44setNotifications((prev) =>45prev.map((n) => (n.id === id ? { ...n, read: true } : n))46)47setUnreadCount((prev) => Math.max(0, prev - 1))48}49 50function markAllRead() {51setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))52setUnreadCount(0)53}54 55return (56 57<div className="max-w-md mx-auto p-4">58<div className="flex items-center justify-between mb-4">59<h2 className="text-lg font-semibold">60Notifications61{unreadCount > 0 && (62<span className="ml-2 inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 rounded-full">63{unreadCount}64</span>65)}66</h2>67<div className="flex items-center gap-3">68<span69className={`inline-block w-2 h-2 rounded-full ${70 connected ? "bg-green-500" : "bg-red-500"71 }`}72/>73<span className="text-xs text-gray-500">74{connected ? "Connected" : "Disconnected"}75</span>76{unreadCount > 0 && (77<button78 onClick={markAllRead}79 className="text-xs text-blue-600 hover:underline cursor-pointer"80 >81Mark all read82</button>83)}84</div>85</div>86{notifications.length === 0 ? (87<p className="text-sm text-gray-400 text-center py-8">88No notifications yet. Waiting for events...89</p>90) : (91<ul className="space-y-2">92{notifications.map((n) => (93<li94key={n.id}95onClick={() => markAsRead(n.id)}96className={`p-3 rounded-lg border cursor-pointer transition-colors ${97 n.read98 ? "bg-white border-gray-200"99 : "bg-blue-50 border-blue-200"100 }`} >101<p className="text-sm">{n.message}</p>102<p className="text-xs text-gray-400 mt-1">{n.timestamp}</p>103</li>104))}105</ul>106)}107</div>108)109}This component connects to an SSE endpoint via EventSource, listens for incoming notifications, displays them with read/unread states, tracks an unread badge count, lets users mark individual or all notifications as read, and shows a connection status indicator.