Sitemap

Real-Time Frontend System Design: Connection Manager, Subscription Layer & Event Processor

12 min readJul 15, 2026

--

Building production-grade streaming UIs that survive disconnects, reconnects, out-of-order events, and thousands of concurrent updates.

Most frontend engineers have built something “real-time.”

A chat application.
A notification badge.
A stock ticker.
An AI response stream.

Most of them work.

Until they don’t.

The moment your application starts handling reconnects, multiple browser tabs, event ordering, duplicate messages, partial updates, transport switching, or thousands of active subscriptions, you’ll quickly realize something:

The WebSocket was never the difficult part.

The architecture around it is.

This article outlines a production architecture that separates networking from rendering and explains why the Connection Manager → Subscription Layer → Event Processor pattern has become the standard behind scalable real-time applications.

The feature

Imagine you’re building a résumé parser.

A user uploads a résumé. Behind the scenes, a language model reads the document and gradually extracts structured information.

  • Personal Information
  • Work History
  • Education
  • Skills

You could show a spinner. Or you could show what’s actually happening.

Press enter or click to view image in full size

Same twelve seconds. The spinner tells you one thing, once. The checklist tells you four things, each the moment it becomes true.

That’s the whole feature. Everything below exists to keep the second bar honest when things go wrong.

The version that works

Here’s the honest starting point. It’s about twenty lines, and it works.

const res = await fetch("/api/extract", { method: "POST", body: pdf });
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";

while (true) {
const { done, value } = await reader.read();
if (done) break;

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop(); // keep the incomplete last line

for (const line of lines) {
const update = JSON.parse(line);
setSections(prev => ({ ...prev, [update.section]: update }));
}
}

Read a chunk, split it into lines, update state. There’s one detail worth noticing: buffer exists because network chunks don't respect line boundaries. A chunk can end halfway through a word. So you hold the incomplete tail and glue it to whatever arrives next.

If your job takes four seconds and your users are on desktops, ship this. I mean that. The rest of this article is about what happens when neither is true.

Five moments where it breaks

Not hypotheticals. These are the five things that actually happen:

  1. Someone walks into a lift. The connection dies at 60%. The checklist freezes forever, looking exactly like it’s still working.
  2. A recruiter uploads twenty CVs. Now, twenty of these things are running, and nobody knows which update belongs to which candidate.
  3. The connection recovers, and the server helpfully resends the last few updates. Your app applies them all again.
  4. Two updates about the same section arrive in the wrong order. A green tick turns back into a spinner.
  5. The model, being a model, says something that isn’t in the document.

Each fix has a name. We’ll get to the names last.

Before anything: who owns the work?

There’s one decision that has to come first, because every other fix depends on it.

In the code above, the browser is doing the work. It opened the stream, and it’s holding the only copy of what’s happened so far.

Press enter or click to view image in full size

If the browser owns the job, there is nothing left to reconnect to.

Move the job to your server. POST /jobs Returns a job ID straight away. A worker on your side talks to the model and writes every update into a log. Your browser just watches that log.

Now the job has a life of its own. Close the laptop, open it on the train, and it’s still there. Nothing else in this article is possible without that.

The five checkpoints

Here’s the one picture worth memorising.

Press enter or click to view image in full size

Every update has to get past five checkpoints before it’s allowed to change what you see. Each asks one question. Each can stop it.

Five checkpoints, five questions:

  1. Connection manager — Am I still connected?
  2. Subscription layer — Is this update even mine?
  3. Event processor — Have I seen it before, and is it in order?
  4. State store — What is true right now?
  5. UI layer — What can I honestly show?

They run in that order, and the order isn’t arbitrary. You can’t sensibly ask whether an update is out of order before you know it was meant for you.

The rest of the article is one section per question.

Checkpoint 1 — The connection manager

“Am I still connected?”

The lift problem. Your while loop stops, and nothing tells the user.

The fix isn’t a try/catch. It's giving the connection a small set of named situations, because loose booleans (isLoading, hasError, isConnected) will start contradicting each other within a week.

Press enter or click to view image in full size

Four situations. Four different things to say.

type Connection =
| { state: "connecting" }
| { state: "live"; lastUpdateId: string }
| { state: "dropped"; lastUpdateId: string }
| { state: "reconnecting"; attempt: number; lastUpdateId: string };

Three things make this work:

Notice the silence. A dead connection often looks identical to a quiet one — the socket stays open, nothing arrives. So the server sends a tiny “still here” ping every 15 seconds, and if the browser hears nothing for 30 seconds, it declares the connection dropped and starts over.

Wait longer each time. Retry after half a second, then one, then two, up to a ceiling. And add a random wobble to each wait:

const wait = (attempt) => {
const ceiling = Math.min(20000, 500 * 2 ** (attempt - 1));
return ceiling * (0.5 + Math.random() * 0.5); // the wobble matters
};

Without the wobble, every open tab retries in lockstep. If a server restarts and a thousand tabs all reconnect at the same millisecond, your frontend has just joined the outage.

Say where you got to. When reconnecting, send the ID of the last update you saw. The server resumes from there instead of starting over.

The connection manager, in one line: keep a live pipe, and be honest about it when it isn’t.

Checkpoint 2 — The subscription layer

“Is this update mine?”

The recruiter with twenty CVs.

The instinct is twenty connections. Don’t. One connection can carry all of it, as long as every update says which job it belongs to.

Press enter or click to view image in full size

One stream, many jobs, one label on every message.

if (update.jobId !== this.jobId) return;   // not for this screen

That’s genuinely most of it. Two things around it matter more than the line itself:

Clean up when the screen goes away. When the user closes the CV, unsubscribe from that job. Otherwise, you’ll accumulate handlers that update components that no longer exist.

Get Gokul P Gangadharan’s stories in your inbox

Join Medium for free to get updates from this writer.

Check ownership on the server every time. A job ID arriving from a browser is a request, not a permission. If a recruiter edits job:a1 to job:a2 In devtools, they should get a rejection, not somebody else's candidate.

The subscription layer, in one line: route updates to the right screen, and make sure that screen is allowed to see them.

Checkpoint 3 — The event processor

“Have I seen this? Is it in order?”

Two problems, one checkpoint. This is the one people skip, and it’s the one that produces bugs with no error message.

(1) Problem one: the same update twice

Worth being clear about the trade you just made. In the original twenty-line version, duplicates were impossible — one connection, one sender, everything in order. The moment you added reconnect-and-resume, you guaranteed them.

Press enter or click to view image in full size

The server can’t know exactly what you finished applying, so it resends a little extra on purpose.

The server deliberately overlaps. It resends from a checkpoint slightly before where you said you got to, because “I received update 6” and “I finished applying update 6” aren’t the same thing, and it can’t tell them apart.

Absorbing the overlap is your job, and it’s small:

const seen = new Set();

function isNew(id) {
if (seen.has(id)) return false; // already applied it — ignore
seen.add(id);
return true;
}

In production, you’d cap that Set, keep the last few hundred IDs, and drop the oldest so it doesn’t grow forever. That’s the entire fix for the entire duplicate problem.

(2) Problem two: the wrong order

Press enter or click to view image in full size

The bug that throws no error and costs you all the trust.

Two updates about Work experience: “done”, then “working…”. The second one was sent earlier — a retry on your worker, a slower request, a replay window. It just arrived later.

If you apply updates blindly, a green tick turns back into a spinner. No errors. The user simply watches a finished section and stops believing the screen.

One rule fixes it: a section can move forward, never backward.

const ORDER = { pending: 0, working: 1, done: 2, failed: 2 };

if (ORDER[incoming.status] < ORDER[current.status]) return state; // stale, ignore

Four lines. And they buy something extra: applying the same update twice now changes nothing, which is exactly what makes replay safe. Ignoring stale updates and surviving duplicates turn out to be the same property.

The question that decides how to merge

There’s one more thing checkpoint 3 has to work out, and it’s the most useful question in this whole article:

Does the user need every update, or only the latest one?

Press enter or click to view image in full size

Checkboxes only need the latest. Getting “Work experience: done” three times is the same as getting it once. Replay is free.

Streamed text needs every single one. If you’re rendering an extracted summary word by word and update 5 goes missing, that’s not a delay — it’s a permanent hole. Update 6 carries the word after the missing one, not instead of it.

So text updates need a different rule: hold anything that arrives early, wait for the gap to fill, and refetch if it never does.

Same stream, two rules. Working out which rule an update follows is the whole job of this checkpoint.

The event processor, in one line: decide whether an update deserves to reach your state at all.

The shape of an update

Now the payoff. Look at what an update has to carry, and notice that every field exists because a checkpoint needs it:

type Update = {
id: string; // "have I seen this?" → checkpoint 3
jobId: string; // "is this mine?" → checkpoint 2
sequence: number; // "what order?" → checkpoint 3
sentAt: string; // "how fresh is this?" → checkpoint 5
section: "personal_info" | "work_experience" | "education" | "skills";
status: "pending" | "working" | "done" | "failed";
data?: unknown;
};

If you only remember one thing from this article, make it this: design that object before you write any UI. Everything upstream exists to produce it; everything downstream exists to consume it. Get it right, and you can rebuild the entire frontend without touching your backend.

The naive version had { section, status, data }. Perfectly fine — right up until the app needed to know whether it had seen something before, where it belonged, or what came first.

Checkpoint 4 — The state store

“What is true right now?”

The state store is a reducer. Updates in, current truth out. Nothing else. No fetching, no rendering, no timers.

Keeping it that boring is the point: it’s a pure function, so you can test every scenario in this article — the duplicate, the stale update, the reconnect — with plain objects and no browser at all.

The state store, in one line: fold updates into the current truth, and do nothing else whatsoever.

Checkpoint 5 — The UI layer

“What can I honestly show?”

The screen has one genuinely hard job: not lying.

Press enter or click to view image in full size

A fake spinner is a lie about progress. A frozen checklist is exactly the same lie wearing better clothes. You already have sentAt in every update; "updated 14 seconds ago" costs you one timestamp and buys back all the trust a silent stall would have spent.

Four rules:

  • Finished sections stay visible during a drop. They’re still true.
  • The busy row stops animating. A spinner with no live connection is a claim you can’t support.
  • Continue stays disabled until you’re back in sync — not because the data is wrong, but because the user is about to act on a partial picture.
  • Announce recovery, not every update. A screen reader firing four times a second is an accessibility failure with good intentions.

The UI layer, in one line: show what the state store says, plus how much you trust it right now.

The AI part: a sender you can’t trust

Everything so far assumed the sender follows its own rules. A language model mostly does.

Press enter or click to view image in full size

The first three are shape problems, and the checkpoints already have the right form for them:

  • Chat before answering. Ignore any line that doesn’t match your format. Do it on the server, so the browser never sees it.
  • Invent a section name. Keep a list of the four names you accept. Anything else gets logged and dropped — never crashes the page. (This is the same guard as checkpoint 2, one level up.)
  • Stops halfway. If the job ends without all four sections, mark it incomplete and offer a retry. Don’t leave a row spinning forever.

The fourth is different. The model invents an employer that isn’t in the CV. Nothing breaks. The JSON is valid, the checkpoints all pass, and the tick goes green. And it’s wrong.

No amount of frontend architecture catches that. What helps is asking the model to show its work:

data: { employer: "DITTO", ... },
meta: {
confidence: 0.62,
foundOnPage: 1, // where in the document this came from
promptVersion: "v7", // so a quality regression traces to a deploy
}

Three extra fields, and the review screen stops being a pre-filled form and becomes a set of claims with evidence. Low confidence gets a “check this” badge instead of quietly being wrong. And there’s a bonus: anything the model produced with no location in the document didn’t come from the document, which is a rather good tripwire for a CV containing hidden text designed to game your extractor.

Then the loop closes. Every correction a reviewer makes is a labelled example, produced by exactly the right person. Send it back as a normal request, and you’re building an evaluation set while people use the product.

Which reframes what you measure. Reconnect counts tell you the pipe is healthy. The correction rate per field tells you the product is.

One thing that surprises people

Deduplication handles the same update twice. It does not handle the same section extracted twice with different answers, which is what you get if you re-run the model.

So a retry should replay from the log, not re-invoke the model. If you genuinely must re-extract, that’s a new attempt, and the screen should say so. Otherwise: a user corrects a field, a retry fires, and a fresh extraction silently overwrites their correction.

Rule: once a human has edited a field, model updates for that field are advisory only. Show that the new extraction disagrees. Never just apply it.

One request, or four?

Press enter or click to view image in full size

Ask once for all four sections, and the model keeps one view of the whole document. It can match dates to job titles and reason across sections. It’s cheapest, because the CV is read once. But progress is lumpy: a model that thinks before writing hands you all four at the end, which makes your beautiful live checklist look exactly like the fake spinner you were trying to avoid.

Ask four times in parallel and sections genuinely finish independently; one failure doesn’t kill the others, and total time is your slowest section rather than the sum. It costs four readings and loses cross-section reasoning, and it guarantees out-of-order updates.

Which is fine. Checkpoint 3 already handles that. The point is to choose it knowingly rather than meet it during an incident.

The whole conversation

Press enter or click to view image in full size

One thing to notice: requests go up as ordinary HTTP, and everything the user sees comes back down the stream. Even the confirmation of their own edit. That’s what keeps a second open tab in agreement — it finds out through the same channel as everything else.

And it’s why you probably don’t need WebSockets here. The only genuinely two-way thing is cancelling a running job, and that’s one POST.

Press enter or click to view image in full size

Learning those five names as a list is how this material becomes hard to remember. Learning them as five questions you already know the answers to is how it becomes hard to forget, which is why we did it in that order.

--

--

Gokul P Gangadharan

Written by Gokul P Gangadharan

I’m a Senior Software Engineer focused on building scalable platforms, performance-driven web apps, and developer infrastructure that supports products at scale