You launch your notification system with a 5-second polling interval. It works. Then you get 500 users and your database is fielding 6,000 notification queries per hour from users who are not even actively looking at their screen. You consider WebSockets -- then realize Vercel's serverless functions cannot keep a persistent connection alive between invocations.
There is a better option sitting inside the web platform that most developers skip: Server-Sent Events (SSE).
SSE is a browser standard for one-way real-time data streaming over a regular HTTP connection. The browser opens one long-lived connection and the server pushes updates as they happen. No WebSocket library. No separate real-time server. No sticky sessions.
For most SaaS notification use cases -- "you have a new message", "your report is ready", "someone commented on your post" -- one-way is exactly what you need. The user does not send updates back through that channel.
SSE works on Vercel because it is just an HTTP response with a special content type and a stream that stays open. The browser connection remains alive through Vercel's edge network, and cleanup happens when the client disconnects.
Notification badges. Instead of hitting /api/notifications/unread-count every 5 seconds from every active tab, the browser opens a single SSE stream. Your server pushes a new count whenever a notification is created.
Background job status. A user triggers a CSV export or data sync. Instead of showing a spinner and polling /api/jobs/[id]/status, you stream progress updates back so they see real progress in real time.
Collaborative activity feeds. When one team member creates a record, other members in the same organization see it appear without a manual refresh.
All three are one-way flows from server to browser -- exactly what SSE was designed for.
An SSE endpoint is a route handler that returns a ReadableStream with content type text/event-stream.
// app/api/notifications/stream/route.ts
export async function GET(req: Request) {
const user = await getUserFromRequest(req);
const stream = new ReadableStream({
start(controller) {
const send = (data: object) => {
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
};
notificationService.getUnreadCount(user.id).then(count => {
send({ unreadCount: count });
});
let last = -1;
const interval = setInterval(async () => {
const count = await notificationService.getUnreadCount(user.id);
if (count !== last) {
last = count;
send({ unreadCount: count });
}
}, 3000);
req.signal.addEventListener("abort", () => {
clearInterval(interval);
controller.close();
});
}
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
}
});
}
On the client side, the native EventSource API opens and maintains the connection automatically:
// In your notification hook
useEffect(() => {
const es = new EventSource("/api/notifications/stream", {
withCredentials: true
});
es.onmessage = (e) => {
const data = JSON.parse(e.data);
setUnreadCount(data.unreadCount);
};
return () => es.close();
}, []);
The browser handles reconnection automatically if the connection drops. You do not need to write retry logic.
SSE still requires the server to do periodic DB queries inside the stream -- it is not magic. What changes is that 500 users with 3-second polling means your server queries the DB 3 times per second per user regardless of whether anything changed. With SSE, you check every 3 seconds but only push when the count changes, so the browser receives far fewer updates and there is no round-trip cost from 500 separate HTTP requests.
The bigger win is connection efficiency. Each browser tab that polls independently sends a full HTTP request with headers and auth tokens on every cycle. An SSE stream is one connection per tab, and the overhead after that is minimal.
For truly high-frequency updates -- live cursors, collaborative text editing, multiplayer state -- you want a dedicated real-time service like Pusher or Ably. SSE is the right tool when updates happen occasionally and "instant" means under 3 seconds.
Use polling when updates are infrequent (less than once a minute) and the user can tolerate a few seconds of lag. It is the simplest option and easy to reason about.
Use SSE when you need updates within 1-3 seconds, the flow is server-to-browser, and you are on a serverless platform like Vercel. Most SaaS notification use cases land here.
Use WebSockets when you need bi-directional real-time communication at very low latency -- live chat, live cursors, collaborative editing. This requires either a separate persistent service or a provider like Pusher.
The Next.js SaaS Boilerplate ships a full in-app notification system with a Drizzle ORM schema, an unread count service, and mark-as-read endpoints. Adding an SSE stream on top is a few dozen lines -- you are connecting a new delivery mechanism to logic that already exists, not rebuilding notifications from scratch.
The existing background jobs pattern shows how the boilerplate handles Vercel's serverless constraints for cron work -- the same constraints that make SSE a better fit than WebSockets for real-time updates.
The auth helper (getUserFromRequest) and error handling (HttpError) work exactly the same inside a streaming route as in a regular route handler.
Ready to ship a SaaS that feels live without the WebSocket infrastructure? Get the boilerplate and add real-time notifications in a day.