Published February 24, 2026
Adding SSE and WebSockets to a Self-Hosted Next.js Stack
A self-hosted Next.js app becomes much more interesting when it is not limited to normal request-response pages.
Modern apps often need live feedback.
A user submits a long-running job and wants to see progress. An AI report is being generated in the background. A dashboard needs fresh updates without refreshing the page. A chat interface needs instant messages. A collaborative tool needs both sides to communicate continuously.
This is where realtime communication comes in.
When you self-host Next.js on your own infrastructure, you are not limited to the default behavior of a frontend platform. You can run long-lived backend processes, tune Nginx, keep connections open, and choose the right realtime pattern for each feature.
The two most useful options are:
SSE → Server pushes updates to browser
WebSockets → Browser and server talk both waysBoth can fit cleanly into a self-hosted stack.
Why realtime belongs in this architecture
A self-hosted Next.js stack is usually not just a frontend.
It often includes:
a backend API
background workers
Redis queues
database updates
AI processing
file imports
scraping jobs
webhook handling
scheduled tasks
Many of these actions happen outside the normal request-response cycle.
Example:
User submits request
↓
Backend creates job
↓
Worker processes job
↓
Worker updates progress
↓
Frontend shows progress liveWithout realtime updates, the user experience becomes awkward.
You either show a spinner for a long time, ask users to refresh the page, or poll the backend every few seconds.
That works, but it can feel crude.
Realtime gives the user confidence that something is happening.
For AI products, this matters even more. If an AI report takes 45 seconds, the difference between a frozen loading screen and a live progress feed is huge.
Instead of:
Generating report...You can show:
Fetching page content...
Analyzing landing page...
Finding competitors...
Comparing alternatives...
Preparing recommendations...
Report completed.This makes the product feel faster, even if the actual processing time is the same.
Polling vs SSE vs WebSockets
Before choosing a realtime approach, it helps to separate the options clearly.
Polling
Polling means the browser asks the server for updates repeatedly.
Example:
GET /api/reports/123/status every 3 secondsPolling is simple.
It works everywhere. It is easy to debug. It does not require long-lived connections. It is often good enough for early versions.
A basic polling loop:
setInterval(async () => {
const res = await fetch("/api/reports/123/status");
const data = await res.json();
updateUI(data);
}, 3000);Good for:
simple dashboards
early MVPs
low-frequency updates
job status checks
admin panels
Downsides:
wasteful if nothing changes
updates are delayed by polling interval
many users can create unnecessary API load
not ideal for rich realtime experiences
Polling is not bad. It is just basic.
Server-Sent Events
SSE lets the server keep an HTTP connection open and push events to the browser.
The browser opens a connection:
const events = new EventSource("/api/reports/123/events");
events.onmessage = (event) => {
const data = JSON.parse(event.data);
updateUI(data);
};The server sends messages like:
data: {"stage":"analyzing","progress":40}
data: {"stage":"completed","progress":100}Good for:
progress updates
AI generation status
logs
notifications
import status
one-way dashboard updates
SSE is usually easier than WebSockets because it uses normal HTTP semantics. The browser has built-in support through EventSource.
Downsides:
server-to-client only
not ideal for chat input or collaborative editing
long-lived connections need correct Nginx config
browser connection limits can matter if abused
For many SaaS products, SSE is the sweet spot.
WebSockets
WebSockets create a persistent two-way connection between browser and server.
The browser can send messages to the server, and the server can send messages back anytime.
Good for:
chat
collaborative apps
multiplayer features
live cursors
realtime dashboards with subscriptions
voice/audio streaming
interactive AI agents
bidirectional control flows
Example browser code:
const socket = new WebSocket("wss://api.example.com/ws");
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
updateUI(data);
};
socket.onopen = () => {
socket.send(JSON.stringify({ type: "subscribe", room: "report-123" }));
};Downsides:
more operational complexity
harder to scale horizontally
needs connection lifecycle handling
needs reconnect logic
may require pub/sub when multiple servers are involved
Use WebSockets when you really need two-way realtime communication.
Do not use WebSockets just because they sound more advanced.
SSE for progress updates
SSE is ideal for background job progress.
Imagine a self-hosted app with this flow:
Next.js frontend
↓
Python API
↓
Redis queue
↓
RQ worker
↓
Database progress updates
↓
SSE stream to browserThe user submits a job:
const res = await fetch("/api/reports", {
method: "POST",
body: JSON.stringify({ url }),
headers: {
"Content-Type": "application/json",
},
});
const report = await res.json();Then the frontend opens an SSE connection:
const source = new EventSource(`/api/reports/${report.id}/events`);
source.onmessage = (event) => {
const update = JSON.parse(event.data);
setStage(update.stage);
setProgress(update.progress);
if (update.status === "completed" || update.status === "failed") {
source.close();
}
};The backend streams updates:
from flask import Response, stream_with_context
import json
import time
@app.route("/api/reports/<report_id>/events")
def report_events(report_id):
def generate():
last_stage = None
while True:
report = get_report(report_id)
payload = {
"status": report.status,
"stage": report.stage,
"progress": report.progress,
}
yield f"data: {json.dumps(payload)}\n\n"
if report.status in ["completed", "failed"]:
break
time.sleep(2)
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)This is a simple version. It checks the database every two seconds and streams changes.
A more advanced version can use Redis pub/sub, so the SSE endpoint receives events when the worker publishes progress.
Example shape:
Worker updates progress
↓
Worker publishes event to Redis
↓
SSE endpoint listens
↓
Browser receives updateThat is more efficient, especially when many users are connected.
But for a small product, database-backed SSE is often enough.
Why SSE works well for AI apps
AI products often have multi-stage workflows:
Queued
Fetching data
Cleaning content
Calling AI model
Parsing result
Generating recommendations
CompletedSSE lets you expose those stages to the user naturally.
Instead of hiding the work behind a spinner, you make the process visible.
This improves trust.
Even better, SSE can stream partial results. For example, your worker can save sections of a report one by one, and the frontend can reveal them as they become available.
The page feels alive.
WebSockets for interactive apps
WebSockets are better when the frontend needs to send realtime messages back to the server.
For example:
User sends chat message
↓
Server receives instantly
↓
Server responds instantly
↓
Other connected users receive messageOr:
User changes document
↓
Server broadcasts change
↓
Other users see updateSSE cannot handle this cleanly because SSE only streams from server to browser. The browser can still send normal fetch() requests, but if communication needs to be continuous and bidirectional, WebSockets fit better.
Common WebSocket use cases:
chat apps
team collaboration
live editing
multiplayer interactions
realtime notifications with acknowledgement
live support tools
interactive AI agents
audio streaming
trading or monitoring dashboards
A minimal Node WebSocket server might look like this:
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8001 });
wss.on("connection", (socket) => {
socket.on("message", (message) => {
const data = JSON.parse(message.toString());
if (data.type === "ping") {
socket.send(JSON.stringify({ type: "pong" }));
}
});
socket.send(JSON.stringify({ type: "connected" }));
});In the frontend:
const socket = new WebSocket("wss://api.example.com/ws");
socket.onopen = () => {
socket.send(JSON.stringify({ type: "subscribe", channel: "dashboard" }));
};
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(data);
};For a Python backend, you might use FastAPI or Django Channels for WebSockets.
Example with FastAPI:
from fastapi import FastAPI, WebSocket
app = FastAPI()
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
while True:
data = await websocket.receive_text()
await websocket.send_json({
"type": "echo",
"message": data
})WebSockets are powerful, but they introduce more state.
You now care about:
connected users
reconnects
missed messages
auth during connection
connection cleanup
heartbeats/pings
horizontal scaling
broadcasting across servers
For serious use, you usually need a message broker like Redis pub/sub, NATS, RabbitMQ, or Kafka behind the WebSocket layer.
Nginx config for streaming connections
Nginx is the front door in a self-hosted setup.
Normal request-response proxying is easy. Streaming connections require a little more care.
Basic Next.js proxy
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}This is enough for normal Next.js pages.
SSE Nginx config
For SSE, you usually want to disable buffering.
location /api/events/ {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600;
add_header X-Accel-Buffering no;
}The important parts:
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600;
add_header X-Accel-Buffering no;Without this, Nginx may buffer the response and the browser may not receive events immediately.
Your backend should also return:
Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: noWebSocket Nginx config
For WebSockets, you need upgrade headers.
At the top-level Nginx config, usually inside http {}:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}Then in your server block:
location /ws/ {
proxy_pass http://127.0.0.1:8001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600;
proxy_send_timeout 3600;
}The important parts:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;Without these, the WebSocket handshake may fail.
Same domain layout
You can serve everything from one domain:
example.com → Next.js
example.com/api/ → Backend API
example.com/events/ → SSE
example.com/ws/ → WebSocketsExample:
server {
listen 443 ssl http2;
server_name example.com;
location / {
proxy_pass http://127.0.0.1:3000;
}
location /api/ {
proxy_pass http://127.0.0.1:8000;
}
location /events/ {
proxy_pass http://127.0.0.1:8000;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600;
add_header X-Accel-Buffering no;
}
location /ws/ {
proxy_pass http://127.0.0.1:8001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 3600;
}
}This keeps the frontend simple because everything is under the same origin.
What changes when you scale horizontally?
Realtime is easy on one server because all connections and application state live in one place.
One server
├── Next.js
├── API
├── SSE connections
├── WebSocket connections
├── Redis
└── WorkersBut once you add multiple servers, things change.
Load balancer
├── App server 1
├── App server 2
└── App server 3Now a user might be connected to App Server 1, but a worker might publish progress from App Server 3.
You need a shared communication layer.
Usually that means Redis, NATS, RabbitMQ, Kafka, or another message broker.
Scaling SSE
With one server:
Worker updates job
↓
SSE endpoint streams to browserWith multiple servers:
Worker publishes progress to Redis
↓
Any app server can receive event
↓
Correct connected browser gets updateA common pattern:
Worker
↓
Redis pub/sub
↓
SSE process
↓
BrowserIf each app server subscribes to Redis, it can forward relevant events to users connected to that server.
You also need to think about reconnects.
SSE has a built-in reconnection behavior, but your backend should be able to resume or send the latest status from the database when the client reconnects.
That is why it is good to store progress in the database, not only in memory.
Scaling WebSockets
WebSockets are trickier because connections are stateful.
If User A is connected to Server 1 and User B is connected to Server 2, a message from User A needs to reach User B.
That requires a shared broker:
Server 1 receives message
↓
Publishes to Redis/NATS
↓
Server 2 receives event
↓
Server 2 sends to connected userYou may also need sticky sessions at the load balancer, where the same user keeps connecting to the same backend server.
But sticky sessions alone are not enough for broadcasting across servers. They help with connection stability, but you still need shared pub/sub for cross-server messages.
Avoid in-memory-only state
On one server, it is tempting to store connected users, job progress, or room data in memory.
That works until you scale.
Better rule:
Temporary connection state can be in memory.
Important application state should be in Redis or the database.For example:
connected socket IDs can be memory
current report status should be database
pub/sub events can be Redis
missed messages should be persisted if they matter
This makes the system easier to scale later.
8. Promotion path: one server to scalable infrastructure
A self-hosted realtime stack can start very simple.
Stage 1: Everything on one server
Single server
├── Nginx
├── Next.js
├── Backend API
├── SSE/WebSocket server
├── Redis
├── Workers
└── DatabaseThis is good enough for:
MVPs
SaaS dashboards
internal tools
early AI products
admin portals
low-to-medium traffic apps
It is simple, cheap, and easy to debug.
Stage 2: Move database out
App server
├── Nginx
├── Next.js
├── API
├── Realtime
└── Workers
Managed PostgreSQLThis makes data safer and backups easier.
Stage 3: Move Redis out
App server
Managed PostgreSQL
Managed RedisNow Redis becomes a shared queue and pub/sub layer.
This is important before adding more app servers.
Stage 4: Split workers
Web server
├── Next.js
├── API
└── Realtime
Worker server
└── Background jobs
Managed Redis
Managed PostgreSQLThis helps when background jobs are CPU-heavy or memory-heavy.
For example, AI jobs, scraping jobs, file processing, video processing, and large imports can run on worker machines without slowing down the web app.
Stage 5: Add load balancer and multiple app servers
Load balancer
├── App server 1
├── App server 2
└── App server 3
Worker servers
Managed Redis
Managed PostgreSQLAt this stage, Redis or another broker becomes essential for realtime coordination.
For SSE:
Worker → Redis → App servers → BrowsersFor WebSockets:
Socket server 1 ↔ Redis/NATS ↔ Socket server 2Stage 6: Dedicated realtime service
Eventually, you may separate realtime completely.
Next.js servers
API servers
Worker servers
Realtime servers
Managed Redis/NATS
Managed PostgreSQLThis is useful when WebSocket traffic grows independently from normal HTTP traffic.
For example, a chat app may have many open connections but not many normal page requests.
Stage 7: Containerize or orchestrate
Once the architecture is mature, you can move to:
Docker Compose
AWS ECS
Kubernetes
Nomad
Render
Railway
managed container platforms
But the important thing is this:
You do not need to start there.
If your app already has clean boundaries, moving to containers later is much easier.
The boundaries are:
Frontend
Backend API
Realtime server
Worker
Database
Redis/message brokerGood boundaries matter more than fancy infrastructure.