When NOT to Reach for NATS: A Simple Image-Sharing Feature
A worked non-use-case: sharing a newly uploaded photo between two browser tabs is better solved with a plain Node.js upload endpoint and Server-Sent Events than by adding NATS as a message broker.
- nats
- anti-pattern
- sse
- architecture
- tutorial
The scenario
User A uploads a photo. User B, on a different computer, should see it appear moments later — no page refresh. It's tempting to reach for "a message broker" the instant "real-time" is mentioned. This article is about recognizing when that instinct is overkill, using this exact scenario.
Why NATS would be overkill here
Every other article in this blog shows NATS solving problems that involve many publishers, many subscribers, subject-based routing, replay, or cross-service decoupling. This scenario has none of that:
- Exactly one event type ("a new image was uploaded").
- A single backend process already sits in the request path (it has to save the file and write to a database either way).
- No fan-out to independent services, no need for subject wildcards, no need for durable replay of missed uploads (a page opened later just loads existing images from the database directly).
Bringing in NATS here means running and operating an entirely separate piece of infrastructure, wiring a publisher into the upload handler, a subscriber into a WebSocket/SSE bridge, and handling NATS connection failures — to solve a problem a single in-process "broadcast to open connections" list already solves completely.
The right-sized architecture: browser SSE + one Node.js process
Step 1 — the server keeps a plain in-memory list of open SSE connections
const listeners = new Set();
app.get('/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
listeners.add(res);
req.on('close', () => {
listeners.delete(res); // error handling: always clean up on disconnect
});
});Step 2 — the upload handler saves the file, then pushes directly to open listeners
app.post('/upload', upload.single('photo'), async (req, res) => {
try {
const filename = req.file.filename;
await db.run('INSERT INTO images (filename, uploaded_at) VALUES (?, ?)', [filename, Date.now()]);
for (const listenerRes of listeners) {
try {
listenerRes.write(`data: ${filename}\n\n`);
} catch (err) {
console.error('[upload] failed to push to a listener, dropping it:', err);
listeners.delete(listenerRes);
}
}
res.status(200).json({ ok: true, filename });
} catch (err) {
console.error('[upload] failed to save uploaded image:', err);
res.status(500).json({ ok: false, error: 'Could not save the uploaded image. Please try again.' });
}
});Step 3 — the browser side, plain EventSource, no client library to install
const evtSource = new EventSource('/events');
evtSource.onmessage = (e) => {
const img = document.createElement('img');
img.src = `/uploads/${e.data}`;
document.getElementById('gallery').appendChild(img);
};
evtSource.onerror = () => {
console.error('[client] SSE connection dropped — the browser will retry automatically');
// Optionally show a "reconnecting..." indicator; EventSource retries on its own.
};When this stops being the right answer — and NATS becomes worth it
This isn't "never use NATS for notifications" — it's about matching the tool to the actual shape of the problem. Revisit the decision once any of these become true:
| Signal | Why it changes the calculus |
|---|---|
| More than one backend process/instance needs to know about the upload (horizontal scaling) | A single process's in-memory listeners list no longer sees every connected client — you'd need to broadcast across processes anyway, which is exactly what a broker is for |
| Multiple independent event types, from multiple independent services (not just "image uploaded") | Subject-based routing and wildcards (see How Publication and Subscription to Subjects Work?) start paying for themselves once there's more than one producer/consumer pairing |
| Consumers need to replay history they missed while disconnected | That's JetStream's job (see the stock-price streaming and fleet-tracking articles), not something a plain SSE broadcast list does |
| The event needs to reach a native mobile app or another backend service, not just a browser tab | NATS client libraries exist for many languages; a browser-only SSE endpoint doesn't naturally extend there |
Troubleshooting (for the SSE approach itself)
| Symptom | Likely cause | Fix |
|---|---|---|
| A browser tab stops receiving updates after a while | Proxy/load balancer in front of Node.js buffers or times out long-lived connections | Configure the proxy (e.g. nginx proxy_buffering off, generous proxy_read_timeout) for the /events route specifically |
| Memory grows over time on the server | Disconnected clients' res objects never removed from listeners | Always remove the listener in the req.on('close', ...) handler, as shown in Step 1 — never rely solely on write() throwing |
| An upload succeeds but no connected browser sees it | The push loop threw before reaching that listener, or the listener disconnected between iteration and write | Wrap each write() individually in try/catch (Step 2) so one broken connection doesn't stop the broadcast to the rest |
| Once scaled to 2+ server instances, only some users get updates | This is exactly the "signal" in the table above — a single process's in-memory list can't see connections held by another instance | This is the point to introduce NATS (or another broker) for cross-instance broadcast, not a bug to patch around |
Recap
- Not every "notify someone when X happens" problem needs a message broker — a single backend process with an in-memory list of open SSE connections is simpler, has fewer moving parts, and is easier to operate for this exact scenario.
- The tell-tale signals for when a broker like NATS earns its keep: multiple backend instances, multiple independent event types/producers, a need to replay missed events, or reaching consumers beyond browser tabs.
- When those signals do appear, the rest of this blog shows the concrete NATS patterns (subjects, queue groups, JetStream, KV) that solve them without hand-rolling broadcast/replay logic yourself.