I recently shipped Do Rent, a property management SaaS with landlord, tenant, and admin roles. One requirement stood out early: landlords and tenants needed to message each other in real time, without a full chat infrastructure or a third-party service.
Here's how I built it with Server-Sent Events (SSE) instead of WebSockets, and what I'd do differently next time.
Why SSE over WebSockets
For Do Rent, messages only flow one direction that matters for "real-time" — server to client. The client still sends messages via a normal POST request; the server just needs to push new messages down to whoever's conversation is open. That's exactly the shape SSE is built for:
- No extra library, no socket server to manage — it's a long-lived HTTP response.
- Auto-reconnect is built into the browser's
EventSourceAPI. - Works cleanly with Next.js API routes and Vercel's serverless model, where a persistent WebSocket server is awkward to run.
WebSockets would have been overkill for a one-directional push, and added a whole reconnection/heartbeat protocol I'd have had to write myself.
The shape of it
On the server, an API route stays open and streams events as new messages are saved:
export async function GET(req: Request) {
const stream = new ReadableStream({
start(controller) {
const send = (data: unknown) => {
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
};
// subscribe to new messages for this conversation
// and call send() whenever one arrives
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
On the client, EventSource subscribes and appends incoming messages to the conversation state:
const es = new EventSource(`/api/conversations/${id}/stream`);
es.onmessage = (event) => {
const message = JSON.parse(event.data);
setMessages((prev) => [...prev, message]);
};
Data lives in MongoDB via Prisma, and every write (a new message, a status change) is what triggers the stream to push. Auth and role checks (landlord vs. tenant vs. admin) happen before the stream ever opens, so a user only ever gets events for conversations they're actually part of. Request/response bodies going into the REST APIs are validated with Zod, so a malformed payload never reaches the database layer in the first place.
What I'd do differently
SSE is a one-way street, which was fine here but won't be if Do Rent ever needs typing indicators or read receipts — those need the client to push small events too, which means falling back to regular POST calls alongside the SSE stream rather than a single connection. It works, but it's not as clean as a bidirectional protocol would be.
The other thing I underestimated: serverless functions and long-lived connections don't always mix well. On Vercel specifically, you have to be deliberate about timeouts and about closing the stream when a client disconnects, or you end up with dangling connections. Worth reading your host's docs on this before committing to SSE for anything at scale.
Repo: github.com/hey-virender/do-rent
Live: do-rent.vercel.app
Happy to answer questions if you're weighing SSE vs. WebSockets vs. polling for something similar.
Top comments (0)