A “live” dashboard that lagged by seconds pushed me to rethink everything about REST. That bug flipped a switch, when do we actually want REST, WebSockets, or gRPC?
I’ve built all three into production systems. I like REST a lot, I get annoyed with WebSockets sometimes, and I’m a fan of gRPC when the use case lines up. The real trick is knowing which tradeoffs you’re signing up for. That’s what I wish someone had told me before I started mixing protocols like a mad scientist. Not great. REST: my default, until it isn’t REST is still my go-to when I’m exposing a public API or building a normal CRUD service. On our billing service rewrite last fall, we had a Node.js app (NestJS 10, Postgres 14) talking to a handful of internal services. I kept it REST because our frontend team and our QA team both understood it, and the tooling was perfect. Postman collections, Swagger UI, a dead simple cache layer at the edge. It just works. And yes, people love to argue about “real REST” and whether you’re using HATEOAS and all that. I don’t care. I care that GET /invoices/123 is obvious, easy to cache, and easy to debug. The strengths I lean on most: Visibility . You can curl it in a terminal, add it to a test suite, or inspect it in your browser. Easy. Caching . I’ve saved a ton on compute by getting cache headers right. Cloudflare or Fastly can do the heavy lifting. Tooling and literacy . The whole stack around REST is mature. Load testing, API gateways, OpenAPI, client SDKs, you name it. Versioning . I can ship v2 without breaking anyone. It’s not elegant, but it’s predictable. Where REST gets annoying: Chatty workflows . Anything that requires multiple round trips feels slow. Polling is the usual hack and it’s not great. High-frequency updates . That order tracking dashboard was the classic “REST didn’t want to do this” moment. Binary payloads . You can do it, sure. But JSON is fat, and we end up compressing or doing weird base64 gymnastics. Here’s a tiny Express example that I still see all the time. It’s not fancy, but you get the idea: import express from "express"; const app = express(); app.use(express.json()); app.get("/orders/:id", async (req, res) => { const order = await db.orders.findById(req.params.id); if (!order) return res.status(404).json({ error: "Not found" }); res.json(order); }); app.listen(3000, () => console.log("REST on 3000")); It’s boring. That’s the point. When I keep REST: Anything external facing. Developers expect it. Anything that needs caching or CDN acceleration. When the data access pattern is mostly “get this thing” and “update that thing.” If that’s your service, don’t overthink it. REST is fine, even in 2024. WebSockets: I reach for these when “near real time” is the requirement I used to avoid WebSockets. Not because they’re bad, but because they feel like work. State, connections, keepalive, scaling. It’s all a little messy. But when you need live updates, you either do WebSockets or you fake it with polling and feel bad about it. That’s the tradeoff. That warehouse dashboard I mentioned? We moved to WebSockets. We used Socket.IO at first (and yes, we paid the extra weight of the protocol), then later went straight to native WebSocket with ws in Node because we wanted fewer dependencies. The improvement was dramatic. The updates felt instant and our database load dropped by a lot because we weren’t polling every 3 seconds. Huge. WebSockets shine for: Live feeds (order tracking, chat, collaborative docs, monitoring dashboards) Server push where the client should not ask over and over Low-latency UX where you can’t tolerate polling lag But you pay for it: Stateful connections . Your servers are holding onto sockets. Load balancing gets interesting. Backpressure . If the client is slow, you can easily flood it. Observability . Debugging live streams isn’t as fun as inspecting a REST response. Scaling . You might need a pub/sub backend or sticky sessions. Redis, NATS, something like that. Here’s a tiny WebSocket server using ws that we used in a prototype: import { WebSocketServer } from "ws"; const wss = new WebSocketServer({ port: 8080 }); wss.on("connection", (ws) => { ws.send(JSON.stringify({ type: "hello", ts: Date.now() })); ws.on("message", (data) => { console.log("incoming", data.toString()); }); }); console.log("WS server on 8080"); And then the client: const ws = new WebSocket("ws://localhost:8080"); ws.onmessage = (event) => { const msg = JSON.parse(event.data); console.log("server:", msg); }; Not complicated, but the operational story is. We had to add ping/pong, a reconnect strategy, and a buffer for the client if it went offline. That stuff adds up. And our SRE lead (who owns the NATS cluster config) was not thrilled. There’s also the question of “do I really need full-duplex?” If you just need server push, SSE (Server-Sent Events) might be a better fit. I’ve used SSE for admin dashboards because it’s simpler. But it doesn’t work everywhere and you can’t send data from client to server on the same channel. WebSockets are still the full-on solution. I tend to choose WebSockets when: UI must feel live and push-based. I control both sides of the connection. I can afford the ops cost. If any of those aren’t true, I start backing away. gRPC: amazing when it fits, annoying when it doesn’t I didn’t really “get” gRPC until we did a service-to-service rewrite. We had a Go inventory service, a Java pricing service, and a Python recommendation service. All internal, behind a service mesh. REST was fine, but the payload sizes were heavy and the contracts were too loose. We were doing data transfer we didn’t need. That drove me nuts. We moved two services to gRPC. It was a lot of work (we had to define protobufs, learn the tooling, adjust our CI), but the results were great. Latency dropped, CPU usage went down, and the type safety saved us from a few dumb bugs. Worth it, though. What I love about gRPC: Strong contracts . Protobufs are strict. You can’t accidentally send a string where a number is expected. Performance . Binary payloads are smaller, and it’s just faster in practice. Streaming . Bidirectional streaming is easy, and it feels like a better WebSocket for internal services. Codegen . We generate client libraries for Go, Java, and Python. Everyone gets the same interface. What’s annoying: Browser support . You’ll probably need gRPC-web or a proxy. That’s not fun. Debugging . It’s not as easy to inspect raw messages, even with tools like grpcurl . Tooling familiarity . Most devs don’t know protobufs the way they know JSON. Here’s a simple protobuf and Go server snippet that I still keep around: syntax = "proto3"; package orders; service Orders { rpc GetOrder (OrderRequest) returns (OrderResponse); } message OrderRequest { string id = 1; } message OrderResponse { string id = 1; string status = 2; } And a quick Go server: type server struct { orders.UnimplementedOrdersServer } func (s *server) GetOrder(ctx context.Context, req *orders.OrderRequest) (*orders.OrderResponse, error) { return &orders.OrderResponse{Id: req.Id, Status: "shipped"}, nil } func main() { lis, _ := net.Listen("tcp", ":50051") grpcServer := grpc.NewServer() orders.RegisterOrdersServer(grpcServer, &server{}) grpcServer.Serve(lis) } I know, I know, it’s basic. But even with a basic example, you can feel the shape of it. The interface is strict. It doesn’t let you be sloppy. And yes, I still have to look up grpcurl flags every time. I reach for gRPC when: It’s internal service-to-service calls. I need speed and consistency. I control the clients. When I don’t: External APIs. Honestly, I still prefer REST here. Frontend-heavy apps unless I’m ready to run a gateway. Teams that don’t want to own proto definitions (some teams just aren’t there yet). The decision tree I actually use I wish I had a cute chart, but I don’t. I use a mental checklist, and I change my mind a lot. Here’s the real version, not the polished one. 1) Is it public or internal? If it’s public, I stick to REST unless there is a strong reason to do otherwise. The developer experience an...