Back to Spy NATS
Spy NATS Blog/ When NOT to Reach for NATS: A Simple Image-Sharing Feature

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.

6 min read
  • 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

server.js — SSE endpoint
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

server.js — upload endpoint
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

client.js
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:

SignalWhy 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 disconnectedThat'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 tabNATS client libraries exist for many languages; a browser-only SSE endpoint doesn't naturally extend there

Troubleshooting (for the SSE approach itself)

SymptomLikely causeFix
A browser tab stops receiving updates after a whileProxy/load balancer in front of Node.js buffers or times out long-lived connectionsConfigure the proxy (e.g. nginx proxy_buffering off, generous proxy_read_timeout) for the /events route specifically
Memory grows over time on the serverDisconnected clients' res objects never removed from listenersAlways 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 itThe push loop threw before reaching that listener, or the listener disconnected between iteration and writeWrap 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 updatesThis is exactly the "signal" in the table above — a single process's in-memory list can't see connections held by another instanceThis 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.

SpyNATS is an independent tool and is not affiliated with, sponsored by, or endorsed by Synadia Communications or the NATS.io project.