Most web apps are built REST-first: the frontend makes HTTP requests, the server responds, and if you need real-time updates, you bolt on WebSocket as an afterthought — maybe for notifications or a chat sidebar. The core data flow stays request-response.
TRCR does it the other way around. WebSocket is our primary transport. Every data fetch, every mutation, every subscription flows through a persistent WebSocket connection first. REST exists only as a fallback for environments where WebSocket isn't available.
This wasn't an obvious choice, and it comes with engineering challenges. Here's why we did it and how it works.
The Problem with REST + WebSocket Bolted On
The traditional approach creates two separate data paths:
- REST for reads and writes (user-initiated actions)
- WebSocket for push updates (server-initiated events)
This sounds clean in theory, but in practice it leads to consistency problems. When you stop a timer via REST, the response confirms it stopped. But other clients find out via a WebSocket event that arrives at a different time, through a different code path, with a different data shape. You end up with two serialization formats, two error-handling strategies, two sets of middleware, and a constant battle to keep them in sync.
We wanted one path. One transport. One way data flows between client and server.
How Our WebSocket Protocol Works
Every message between client and server follows a simple JSON envelope:
// Client → Server (request)
{
"id": "req_abc123",
"action": "time_entries.start",
"payload": {
"project_id": "proj_xyz",
"description": "API integration",
"billable": true
}
}
// Server → Client (response)
{
"id": "req_abc123",
"status": "ok",
"data": {
"id": "te_789",
"started_at": "2026-03-05T14:30:00Z",
...
}
}
// Server → All Clients (broadcast event)
{
"event": "TimeEntryStarted",
"org_id": "org_456",
"data": {
"user_id": "usr_123",
"entry_id": "te_789"
}
}The client sends a request with a unique id, anaction that maps to a backend handler, and apayload. The server responds with the sameid so the client can match responses to requests. Meanwhile, broadcast events flow to all connected clients in the same organization without being tied to a specific request.
Request-Response Over WebSocket
This is the key insight. WebSocket doesn't have to be fire-and-forget. By including a request ID, we get the same request-response semantics as HTTP, but over a persistent connection. The client's transport.request()function returns a Promise that resolves when the matching response arrives:
// Frontend transport layer (simplified)
async function request(action: string, payload: any) {
const id = generateId();
// Try WebSocket first
if (ws.connected) {
ws.send({ id, action, payload });
return waitForResponse(id); // Promise resolves on matching response
}
// Fallback to REST
return httpRequest(actionToUrl(action), payload);
}The calling code doesn't know or care whether the request went over WebSocket or HTTP. The transport layer decides.
The Fallback Layer
Not every environment supports WebSocket. Corporate proxies, aggressive firewalls, and some mobile networks can interfere with persistent connections. We needed REST as a safety net.
Our approach: every WebSocket action has a corresponding REST endpoint. The action name maps directly to a URL path:
time_entries.start→POST /api/time-entries/starttime_entries.stop→POST /api/time-entries/stopprojects.list→GET /api/projectstasks.update→PATCH /api/tasks/:id
Both the WebSocket handler and the REST handler call the same service layer underneath. The only difference is the transport. This guarantees that behavior is identical regardless of which path the request takes.
On the server side, both paths share the same auth middleware, the same validation, and the same business logic:
// Shared service layer — called by both WS and REST handlers
impl TimeEntryService {
pub async fn start_timer(
&self,
org_id: &str,
user_id: &str,
req: StartTimerRequest,
) -> Result<TimeEntry> {
// Validation, DB insert, broadcast event
// Identical regardless of transport
}
}What About GraphQL?
We also expose a GraphQL API on the same server. GraphQL serves a different purpose — it's for clients that need flexible queries (fetching nested data in a single request) or for third-party integrations that want to query exactly the fields they need.
GraphQL handlers call the same service layer as REST and WebSocket. Three interfaces, one source of truth.
Real-Time Broadcasts
When something happens that other users need to know about, the service layer emits a broadcast event. The WebSocket layer picks it up and fans it out to all connected clients in the relevant organization:
- TimeEntryStarted / TimeEntryStopped — team leads see who's working in real time
- TaskCreated / TaskUpdated — Kanban boards update instantly
- ChatMessage — messages appear without polling
- InvoiceCreated — admins get instant visibility
- NotificationCreated — the bell icon updates live
Clients that are connected via REST (fallback mode) don't receive broadcasts. They rely on polling or manual refresh. This is a deliberate tradeoff — the vast majority of users (99%+) are on WebSocket, and the REST fallback is there for the edge cases, not as a first-class real-time transport.
Connection Management
Persistent connections come with challenges that HTTP doesn't have:
Heartbeats
We send a ping frame every 30 seconds. If the client doesn't respond with a pong within 10 seconds, we close the connection. This detects dead connections (e.g., user closed their laptop lid) and frees resources.
Reconnection
The frontend transport layer automatically reconnects with exponential backoff if the connection drops. On reconnection, it re-authenticates using the stored JWT and re-subscribes to the organization's event stream. The user doesn't notice unless the disconnection lasts more than a few seconds.
Authentication
The initial WebSocket handshake includes the JWT access token. If the token expires during a long-lived connection, the server sends a TokenExpired event. The client refreshes the token via REST (the one case where REST is used even when WS is available) and sends the new token over the existing connection.
Performance Characteristics
Compared to REST-only architectures:
- No connection overhead per request. HTTP/1.1 requires a new TCP connection (or connection reuse with keep-alive). WebSocket maintains one connection for all requests. This saves ~50ms of TLS handshake per request.
- No header bloat. HTTP headers add 200-500 bytes per request. WebSocket frames have a 2-14 byte overhead. For apps that make 50+ requests per page load, this adds up.
- Instant push. No polling interval. Events arrive as fast as the server can emit them, typically under 5ms end-to-end.
Key Takeaway
WebSocket-first doesn't mean WebSocket-only. The pattern is: use WebSocket as the primary transport for both request-response and push, keep REST as a fallback that shares the same service layer, and make the transport decision invisible to the calling code.
Should You Build This Way?
If your app is primarily CRUD with occasional real-time features (like notifications), REST-first with WebSocket bolted on is simpler and probably the right call.
But if real-time is core to your product — if users expect to see each other's actions instantly, if timers need to sync across devices, if chat is a first-class feature — then WebSocket-first eliminates an entire class of consistency problems and gives you a snappier user experience.
For TRCR, where every feature (timers, tasks, chat, invoices) benefits from real-time sync, the choice was clear.