Keeping the Frontend Responsive Under Heavy Load
Lessons in concurrency from modular, event-driven architectures
Keeping the Frontend Responsive Under Heavy Load
Lessons in concurrency from modular, event-driven architectures
“Rethinking the Client: A New *Era of Modular, Performant Frontends” | **Part 14***

Understanding the main thread in the browser — Good, luck!
I've encountered this problem on both mobile and web platforms, but it primarily affects the JavaScript world. Even now, most web applications and frameworks run everything on the main thread by default. Sometimes I wonder if desktop CPUs have just become fast enough to hide this kind of poor architecture, or if it's more about a lack of knowledge among frontend developers, or a combination of both.
When I first started considering Client-side Microservices Architecture (CSMA), it wasn't because I wanted something fancy. It originated from a simple idea: having a native multithreading system for the web using Web Workers. I had already experienced the pain of not having it, or worse, adding it too late in a project and leaving each team to manage workers independently. In enterprise applications, where multiple teams with different skill sets develop features simultaneously, everyone believes their code should have top priority. Without a plan, concurrency quickly turns into chaos.
I still recall a large project where each team managed a portion of a highly complex application. One minor feature was parsing thousands of messages per second and updating the DOM in real time. At first, everything seemed fine; hardware was fast enough, and animations looked smooth. But a few months later, during a leadership meeting, QA reported that a critical notification had failed to trigger in production. That single failure caused the application to reach an invalid state, resulting in a loss of millions of dollars for the company.
In the meeting room, developers argued that if there were no logs, the problem couldn't have occurred. But the real cause was something everyone had overlooked: a minor feature introduced months earlier by a satellite team that occasionally blocked logging and froze the application. In other words, it was the perfect storm. With a different architecture and proper multithreading, the issue could have been completely prevented.
Multithreading isn't just about keeping the UI smooth; it can literally save you millions on your next project.
Why Responsiveness Breaks Without Orchestration
Modern frontends typically don't fail because of a single bug; instead, they fail because everything competes for the same main thread. Most JavaScript frameworks are designed to schedule nearly all work on this thread, including rendering, event handling, data processing, and background synchronization. As CPUs continue to become faster, this setup can temporarily mask their flaws, but over time, the increasing load will reveal the issues.
The result is familiar: clicks feel delayed, animations stutter, and tasks clash until the user becomes aware of the issue. In large enterprise apps, the stakes are higher because multiple teams often ship services independently. Without coordination, those services behave like drivers without traffic lights. Each one assumes it has the right of way until the intersection jams and responsiveness fails.
What worsens this is the scale. A single widget can manage the main thread effectively, but a hundred services, each with its own logic and update loop, cannot. Without runtime orchestration to distribute workloads, the system begins to fail in unpredictable ways that no individual team can foresee or control.

Diagram: Without orchestration, all services post tasks into the same main thread queue, creating contention and starving responsiveness.
How CSMA Orchestrates Workloads
Responsiveness isn't luck; it's about scheduling. In CSMA, the Thread Manager functions like air traffic control for the runtime. It determines where each task should go, when it should run, and what it must never block.
- The main thread is reserved for user input, rendering, and the smallest possible slices of orchestration work.
- Worker pool handles CPU-bound tasks, parsing, transforms, and model evaluations.
- Background queue takes on prefetch, cache warmup, and analytics so they never steal time from user input.
Two rules keep the system honest:
- Priority first, fairness second. High-priority interactions preempt lower-priority work, but long-running low-priority tasks are still given periodic slices so they do not starve.
- Do the minimum on the main thread. If a unit of work can run off the main thread, it does. If it cannot, it must be short, predictable, and cancelable.

Diagram: The Thread Manager routes tasks by priority and context, preempting on user input, batching background work, and adapting to live signals such as frame budget and CPU load.
Small, practical example
A minimal pattern that shows the intent, not a complete framework:
// ThreadManager sketch: priority aware, cancelable, and worker offload
class ThreadManager {
constructor(workerUrl, maxWorkers = navigator.hardwareConcurrency || 4) {
this.workerUrl = workerUrl;
this.pool = [];
this.free = [];
for (let i = 0; i < Math.max(1, Math.min(8, maxWorkers)); i++) {
const w = new Worker(workerUrl, { type: "module" });
w.onmessage = e => w._resolve && w._resolve(e.data);
w.onerror = e => w._reject && w._reject(e);
this.pool.push(w);
this.free.push(w);
}
this.queues = {
high: [],
normal: [],
background: [],
};
}
schedule(task, { priority = "normal", onMain = false, signal } = {}) {
const job = { task, onMain, signal, t0: performance.now() };
this.queues[priority].push(job);
this._drain();
}
async _runOnWorker(job, w) {
if (job.signal?.aborted) return;
return new Promise((resolve, reject) => {
w._resolve = resolve;
w._reject = reject;
w.postMessage({ type: "EXEC", payload: job.task.payload });
});
}
_nextJob() {
return this.queues.high.shift()
|| this.queues.normal.shift()
|| this.queues.background.shift();
}
_drain() {
// Give high priority jobs a short main thread slice if required
let job;
while ((job = this._nextJob())) {
if (job.signal?.aborted) continue;
if (job.onMain) {
// Keep main thread work tiny and cooperative
queueMicrotask(() => job.task.run());
continue;
}
const w = this.free.pop();
if (w) {
this._runOnWorker(job, w)
.catch(() => {}) // isolate failures
.finally(() => {
this.free.push(w);
// Periodically allow background to progress to avoid starvation
if (performance.now() - job.t0 > 16 && this.queues.background.length) {
const bg = this.queues.background.shift();
if (bg) this.schedule(bg.task, { priority: "background" });
}
this._drain();
});
} else {
// No worker free, requeue with slight demotion to keep fairness
const q = job.task.critical ? "high" : "normal";
this.queues[q].unshift(job);
break;
}
}
}
}
// Usage
const tm = new ThreadManager("/worker.js");
// Critical user action, tiny main thread slice:
tm.schedule({ run: () => applySelection(delta) }, { priority: "high", onMain: true });
// CPU heavy transform offloaded:
tm.schedule({ payload: { kind: "transform", data } }, { priority: "normal" });
// Background prefetch that must never block:
tm.schedule({ payload: { kind: "prefetch", url } }, { priority: "background" });
Concurrency Without Chaos
Concurrency in the browser is complex because JavaScript provides only a single main thread by default. Add Web Workers, and you'll get parallelism, but without proper rules, workers can block each other or overload the system with too many tasks. CSMA addresses this with coordination, not just launching threads.
The runtime treats concurrency as a coordinated resource, not a free-for-all. That means:
- Workload distribution is bounded. The system only creates as many workers as the environment can sustain, usually tied to the number of logical CPU cores.
- Task queues are isolated. A misbehaving service cannot overwhelm the entire runtime, because its workload is contained to its own channel.
- Backpressure is applied. If a service pushes more tasks than workers can handle, tasks queue up and are throttled instead of flooding the system.
Without these controls, concurrency descends into chaos. The outcome is either a UI that freezes because background work spills back into the main thread, or runaway workers that consume CPU until the system terminates them. With orchestration, concurrency becomes a shared, predictable system fabric.

Diagram: CSMA enforces bounded concurrency, isolating each service’s queue and distributing work across a limited pool of workers. This prevents starvation and keeps the system stable under load.
Conceptually, orchestration seems elegant, but engineers often want practical insights. Here's a straightforward worker-pool example illustrating three key principles of CSMA's concurrency model: workers are limited by device capacity, services maintain separate queues preventing interference, and backpressure allows the system to fail gracefully rather than collapse under heavy load. This isn't production code but demonstrates the essential mechanics of CSMA's runtime scheduling.
Great, here is a tight, copy‑pasteable example showing a bounded worker pool with per‑service queues and backpressure.
// Bounded worker pool with per‑service queues and simple backpressure
class WorkerPool {
constructor(url, size = Math.max(1, Math.min(8, navigator.hardwareConcurrency || 4))) {
this.url = url;
this.size = size;
this.workers = Array.from({ length: size }, () => this._spawn());
this.free = [...this.workers];
this.queues = new Map(); // serviceId -> Array<job>
this.maxQueue = 256; // backpressure threshold per service
}
_spawn() {
const w = new Worker(this.url, { type: "module" });
w.busy = false;
w.onmessage = e => {
w.busy = false;
w._resolve && w._resolve(e.data);
w._resolve = null; w._reject = null;
this.free.push(w);
this._drain();
};
w.onerror = e => {
w.busy = false;
w._reject && w._reject(e);
w._resolve = null; w._reject = null;
this.free.push(w);
this._drain();
};
return w;
}
enqueue(serviceId, payload, opts = {}) {
const q = this._queueFor(serviceId);
if (q.length >= this.maxQueue) {
if (opts.dropIfBusy) return false; // drop to apply backpressure
throw new Error(`Backpressure: queue full for service ${serviceId}`);
}
const job = { serviceId, payload, prio: opts.prio || 1, signal: opts.signal };
q.push(job);
// Optional: sort by priority, lower is higher priority
q.sort((a, b) => a.prio - b.prio);
this._drain();
return true;
}
_queueFor(id) {
if (!this.queues.has(id)) this.queues.set(id, []);
return this.queues.get(id);
}
_nextJob() {
// Fair round‑robin across services to avoid starvation
const services = [...this.queues.keys()];
for (const id of services) {
const q = this.queues.get(id);
while (q && q.length && this.free.length) {
const job = q.shift();
if (job.signal?.aborted) continue;
return job;
}
}
return null;
}
_dispatch(job) {
const w = this.free.pop();
if (!w) return;
w.busy = true;
return new Promise((resolve, reject) => {
w._resolve = resolve;
w._reject = reject;
w.postMessage({ type: "EXEC", serviceId: job.serviceId, payload: job.payload });
});
}
_drain() {
let madeProgress = false;
while (this.free.length) {
const job = this._nextJob();
if (!job) break;
this._dispatch(job);
madeProgress = true;
}
return madeProgress;
}
}
// Example usage
const pool = new WorkerPool("/worker.js", 4);
// High priority user action for Service A
pool.enqueue("svc:A", { op: "transform", data: bigChunk }, { prio: 0 });
// Normal work for Service B
pool.enqueue("svc:B", { op: "score", items }, { prio: 1 });
// Background prefetch for Service C, drop if system is busy
pool.enqueue("svc:C", { op: "prefetch", url }, { prio: 2, dropIfBusy: true });
// In /worker.js
self.onmessage = e => {
const { serviceId, payload } = e.data;
// Do CPU work here, keep it pure and deterministic
const result = execute(serviceId, payload);
self.postMessage({ ok: true, result });
};
function execute(serviceId, payload) {
// Demo only
if (payload.op === "transform") return payload.data; // compute something
if (payload.op === "score") return payload.items.length;
if (payload.op === "prefetch") return true;
return null;
}
Scaling With Complexity
Concurrency isn't just about dividing tasks among multiple threads; it's about scaling those decisions as the number of services and interactions increases. A small app can rely on ad-hoc workers, but once you have dozens of independently developed services, the rules of orchestration need to grow with them.
CSMA handles this by layering policies:
- Global fairness. No single service can monopolize the system, even if it has high-volume workloads. Scheduling policies ensure round-robin fairness and prevent starvation.
- Priority bands. Critical tasks, such as user interactions and visual updates, always have a reserved path through the scheduler. Lower-priority work, like background analytics, only runs when the system is healthy.
- Adaptive scaling. The runtime can expand or contract concurrency levels based on observed system load, so a busy browser tab on a laptop does not behave the same as an idle tab on a workstation with many cores.
The main insight is that orchestration needs to be flexible. Static thread counts or fixed priorities quickly fail as complexity grows. By designing the runtime to be adaptive, CSMA builds a system where more services can join without losing the responsiveness of the entire app.

Diagram: As service counts grow, CSMA layers fairness, priority bands, and adaptive scaling to keep concurrency predictable and responsive.
Avoiding Starvation and Protecting UX
Fairness is essential. Without it, low-priority tasks never complete, and systems drift into hidden failure modes. CSMA avoids starvation with three simple strategies:
- Reserve budget for user input and rendering. The main thread gets short, predictable slices only.
- Guarantee progress for low-priority queues. Even during heavy interaction, background tasks receive periodic slices.
- Use cooperative cancellation. Any slice can be aborted when fresh user input arrives.
Small, practical example
A cooperative scheduler loop that allocates a fixed slice of time for background tasks each frame, without blocking input. It uses frame pacing and has a timeout as a safety measure.
// Cooperative fairness: guarantee progress for background tasks without hurting UX
const high = []; // high priority jobs, tiny and on main thread only
const background = []; // background jobs, chunked and interruptible
let inputPending = false;
addEventListener("pointerdown", () => { inputPending = true; }, { passive: true });
addEventListener("pointerup", () => { inputPending = false; }, { passive: true });
function enqueueHigh(fn) { high.push(fn); }
function enqueueBackground(fn) { background.push(fnChunked(fn)); }
// Break a long task into chunks that can yield between steps
function fnChunked(fn, step = 250) {
let i = 0;
return function runChunk(deadline) {
const t0 = performance.now();
while (i < step) {
fn(i++); // do a small unit of work
if (inputPending) return; // cooperative cancellation on input
if (deadline && !deadline.timeRemaining()) break;
if (performance.now() - t0 > 6) break; // keep slice tiny
}
if (i < step) schedule(runChunk); // reschedule remaining chunks
};
}
function schedule(task) {
// Try idle time first, fall back to a short timeout to guarantee progress
if (window.requestIdleCallback) {
requestIdleCallback(task, { timeout: 50 });
} else {
setTimeout(() => task({ timeRemaining: () => 0 }), 8);
}
}
// Main loop: do minimal high priority work each frame, then let background progress
function tick() {
// 1) Tiny slice for high priority work
const maxHighOps = 3;
for (let i = 0; i < maxHighOps && high.length; i++) {
const job = high.shift();
try { job(); } catch {}
}
// 2) One background job gets a chance to progress
if (background.length) {
const job = background.shift();
schedule(job);
// Round robin to avoid one job hogging the lane
background.push(job);
}
requestAnimationFrame(tick);
}
// Start the cooperative loop
requestAnimationFrame(tick);
// Example usage
enqueueHigh(() => applySelection(delta)); // tiny UI update
enqueueBackground((i) => heavyTransformStep(i)); // chunked CPU work
Takeaways
- Orchestration beats micro-optimizations. You keep the app responsive by placing work in the right place at the right time, not by squeezing more out of the main thread.
- Treat the main thread as sacred. Only do tiny, predictable slices there. Everything else goes to workers or background queues.
- Use bounded pools and per‑service queues. Limit parallelism to device capacity and isolate noisy services so they cannot trample others.
- Combine priority with fairness. High-priority work preempts, low-priority work still gets periodic slices, so it never starves.
- Apply backpressure. If producers outpace consumers, the system should queue, throttle, or drop by policy instead of collapsing under load.
- Adapt to real signals. Scale concurrency and scheduling based on frame budget, input latency, CPU load, and queue depth.
- Build in cooperative cancellation. Yield immediately when fresh user input arrives.
- Observe everything. Track queue lengths, task durations, frame timing, and preemption events so policies can evolve with reality.
Coming Up Next
In Part 15, we will move from threading and concurrency into how services actually talk to one another at runtime. Instead of relying on direct calls that couple services together, we will explore why CSMA leans on eventing. Contracts over calls, in the form of pub/sub messaging, allow services to emit and consume events without knowing each other’s internals. This pattern makes isolation real, scales across teams, and keeps the runtime resilient even when individual services fail. If you have ever wondered why direct access becomes a liability in modular frontends, Part 15 will unpack the event-driven alternative and show how to design contracts that stand the test of scale.

(Monday)
Stay tuned.
🤔 Wait, Isn’t This Just Micro Frontends?
Not quite.

Micro frontends focus on splitting up the UI, allowing different teams to own and manage various parts of the visual interface, which are often deployed separately.
**Client-side Microservices Architecture (CSMA) is different: it’s not about the UI at all. It’s about breaking up business logic components, such as state management, calculations, workflows, and side effects, into independent, testable services** that run inside the client app.
Think of it as giving your frontend the same internal structure and discipline as a backend system, without turning your components into dumping grounds for logic.
You can use CSMA with or without micro frontends. They solve different problems, and they complement each other well.
🧱 Found this helpful?
If this post helped clarify how to think about modular frontend logic, give it a 👏 or share it with someone buried under spaghetti code.
📚 Following the series? This article is part of the ongoing series: Client-side Microservices: Rethinking Frontend Architecture
Each post breaks down how to bring structure, scalability, and sanity to modern frontend development, one small service at a time.
메타데이터
- post_id
- c677563fe5d2
- slug
- keeping-the-frontend-responsive-under-heavy-load-c677563fe5d2
- url
- https://medium.com/rethinking-the-client-a-new-era-of-modular/keeping-the-frontend-responsive-under-heavy-load-c677563fe5d2
- canonical_url
- https://medium.com/rethinking-the-client-a-new-era-of-modular/keeping-the-frontend-responsive-under-heavy-load-c677563fe5d2
- author_url
- https://medium.com/@enricopiovesan
- status
- ok
- fetched_at
- 2026-07-18 03:35:40