Protocol debates often devolve into gRPC versus GraphQL scorecards that ignore product constraints. When I architected the communication layer on elangodev.com, my question was simpler: which transport keeps chat feeling instant while keeping mutations auditable and secure? The answer was a hybrid. Supabase Realtime delivers postgres change events to the browser over WebSockets, while Next.js route handlers expose REST endpoints for sends, edits, uploads, and admin actions.
Why I Did Not Use One Protocol for Everything
Early in the project I considered pushing all chat operations through Realtime broadcast channels. That works for typing indicators — ephemeral events that never touch the database — but message sends need durable storage, authorization, push notification side effects, and consistent error codes. REST route handlers give me explicit HTTP semantics: 401 for expired sessions, 403 for blocked users, 201 for successful inserts. WebSocket events alone would force me to reinvent status codes in custom payloads.
My contact form reinforces the split. It uses a plain POST to /api/messages with JSON body validation. There is no realtime subscription because visitors do not need live updates; they need confirmation that the message persisted. Choosing REST here keeps the public surface minimal and cache-friendly at the CDN edge.
Realtime for Read Models and Presence
Chat read paths are where Realtime shines. In ChatContent.jsx I subscribe to postgres changes on chat_messages and message_reactions, plus presence sync for online users and broadcast events for typing. When a row inserts, the handler checks whether the current user is admin or a participant before merging the message into local state.
// app/chat/ChatContent.jsx — Realtime CDC subscription
const channel = supabase.channel('chat-room', {
config: { presence: { key: session.user.id } },
});
channel
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'chat_messages',
}, async (payload) => {
if (payload.eventType === 'INSERT') {
handleNewMessageRef.current(payload.new);
}
})
.subscribe();
This is Change Data Capture exposed as a protocol choice: PostgreSQL remains the source of truth, and Realtime propagates diffs. I avoid polling /api/chat every few seconds, which would hammer Vercel functions and Supabase connections on mobile clients.
REST for Writes and Privileged Operations
When a user sends a message, the client POSTs to /api/chat/send with a fresh bearer token from getFreshToken(). The route validates identity, block status, and payload shape before inserting via the admin client. Edits, deletes, and file uploads follow the same pattern — one route per mutation with clear authorization rules.
// Client send path — REST mutation after optimistic UI
const resp = await fetch('/api/chat/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token,
},
body: JSON.stringify(payload),
});
if (!resp.ok) {
const errBody = await resp.json().catch(() => ({}));
throw new Error(errBody.error || 'Send failed');
}
I refresh tokens before each call because mobile browsers suspend tabs and stale JWTs caused confusing 401 loops. REST makes that retry logic straightforward; with a pure Realtime send I would lack standard status handling.
Edge Streaming for the Portfolio AI Assistant
The homepage AI assistant uses a different protocol again: Server-Sent Events via the Vercel AI SDK on an Edge runtime route at /api/chat. That route streams Gemini tokens with low time-to-first-byte, which matters on the Hobby plan execution limit. It is read-heavy, ephemeral, and anonymous — unlike authenticated chat messages. I exported runtime = "edge" there while keeping blog generation on Node because it needs longer CPU time and S3 uploads.
Protocol selection followed workload shape, not ideology. Realtime CDC for synchronized state, REST for authoritative writes, Edge streaming for lightweight assistant responses.
Design Rules I Reuse on Client Projects
First, classify each feature as durable or ephemeral. Durable events belong behind REST or RPC with explicit auth. Ephemeral signals — typing, presence, live cursors — can live on WebSocket channels. Second, never duplicate business rules across protocols. My send validation lives only in the route handler; Realtime listeners assume the row already passed gates. Third, instrument connection status separately from HTTP health. I surface SUBSCRIBED versus CONNECTING in the chat UI so users know when to retry sends.
Fourth, log correlation IDs on REST writes even if Realtime delivers the same event milliseconds later — when debugging duplicate messages I need to know whether the client retried POST or Realtime replayed an insert. These rules are boring; they prevent the exciting outages.
Architecting the communication layer is less about picking a winner in the gRPC-GraphQL debate and more about mapping protocols to consistency requirements on your actual product. On elangodev.com that map keeps chat reactive without sacrificing the security I need on every write.
When I onboard new features — reactions, read receipts, attachment uploads — I ask which protocol owns the source of truth before writing UI code. That single decision prevents the split-brain bugs I see in startups that broadcast mutations over WebSockets without idempotent REST fallbacks. My chat survived mobile network flapping because sends always reconcile against Postgres through explicit HTTP responses even while Realtime delivers the optimistic experience. The lesson applies beyond chat: pick the protocol that matches consistency, not the one with the best conference talk. Document that choice in ADRs so the next contributor does not collapse layers for convenience.




