
Journal entry
Processing Heavy Data on the Front-End Without Blocking the Browser
A practical async yielding pattern for splitting expensive front-end work across frames.
Introduction
Nowadays users expect near-native performance from mobile and desktop web applications. This poses some serious challenges on frontend engineers, especially when developing data-intensive applications like real-time dashboards, graph visualizations and other feature-rich and data-heavy types of web apps.
While web platform continuously evolves and even low-end user devices become very powerful, there are still some key limitations we as developers need to consider when processing a lot of data in UI and the main limitation we talk about today is single-threaded nature of JavaScript and the competition that naturally arises between important browser tasks and our JS code that all need to be executed on the main thread.
Below we dive into this problem and I offer a simple technique for localized optimizations that have overcome this limitation and improve website performance.
Understanding the Problem
The browser has a lot to coordinate on the main thread: JavaScript, user events, style recalculation, layout, and painting. When your code starts a long synchronous loop, the browser cannot pause it halfway through to handle a click or paint a loading state. It has to wait until your task finishes.
That is why a calculation that takes a few hundred milliseconds can make an otherwise good interface feel broken. The page may look frozen even if the code is technically making progress.
At 60 frames per second, each frame has roughly 16 milliseconds available. Your JavaScript does not own all of that time. The browser still needs room to do its work. One simple way to both: keep heavy computation in UI and allow browser to do it's work is to chunk the computation over multiple frames.
Async Yielding and Chunking Computations
Async yielding is useful because it changes one large task into many smaller tasks, giving the browser regular chances to breathe.
This does not make the total computation free. On the contrary, it will likely make the overall computation time longer. The tradeoff is that the interface can keep rendering progress and responding to user events while the work continues.
The trick to it is to embrace following mental model (and implement it in code for your specific data structure):
- Convert long-running synchronous operation into asynchronous operation
- Check how much time passed since you started this batch after each iteration
- Yield, in other words - release the main thread and schedule the rest of your work to continue later
- Repeat and accumulate partial results until processing is done
The time budget matters. If the budget is too large, the UI still feels blocked. If the budget is too small, the work may take noticeably longer because you spend too much time scheduling. A budget around 4-8 milliseconds is often a reasonable starting point, then you adjust based on profiling and how much rendering work the page also needs to do.
Here is a minimal example:
Yielding Heavy Work Between Frames
Compare synchronous Fibonacci processing with a chunked async generator that yields between batches.
Walking Through the Pattern
The sample has two buttons that run the same work implemented in two ways. One version runs it all synchronously, the other version uses an async generator and yields back to the browser between batches.
The tiny helper is still the main trick:
const nextFrame = () =>
new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));requestAnimationFrame gives us ability to 'schedule' next chunk of work. When the processing loop below reaches its time budget, it awaits nextFrame(). This gives browser a chance to render and process user events.
The important part is the async generator:
async function* createValuesInBatches(seed: number) {
const values = new Uint32Array(RESULT_COUNT);
let index = 0;
let batches = 0;
while (index < values.length) {
const batchStart = performance.now();
while (
index < values.length &&
performance.now() - batchStart < BATCH_BUDGET_MS
) {
values[index] = fibonacciWorkload(index, seed);
index += 1;
}
batches += 1;
yield { values, index, batches } satisfies BatchProgress;
await nextFrame();
}
}Let's break it down. Main while loop continues until entire processing is done. Inner loop is our 'batch'.
We start by recording the time using performance.now() - it gives a precise timestamp. After each Fibonacci value is calculated, it checks how much time has passed. Once the current batch has used its budget, the generator yields current progress and then awaits nextFrame().
The useful thing about async generators here is that the function keeps its internal state and decides what should be exposed to the outside world when it yields.
The optimized runner consumes it like this:
async function runOptimized(seed: number): Promise<RunResult> {
const startedAt = performance.now();
let progress: BatchProgress | undefined;
for await (const nextProgress of createValuesInBatches(seed)) {
progress = nextProgress;
console.log(
'Optimized batch ' +
nextProgress.batches +
': ' +
Math.round((nextProgress.index / RESULT_COUNT) * 100) +
'% complete'
);
}
return {
mode: 'optimized',
seed,
values: progress?.values ?? new Uint32Array(RESULT_COUNT),
elapsedMs: performance.now() - startedAt,
batches: progress?.batches ?? 0,
};
}We keep receiving progress on each generator yield, but overall we await the entire computation before returning.
The unoptimized version just runs all fibonacci calculations synchronously:
function runSynchronously(seed: number): RunResult {
const startedAt = performance.now();
const values = new Uint32Array(RESULT_COUNT).map((_, index) =>
fibonacciWorkload(index, seed)
);
return {
mode: 'unoptimized',
seed,
values,
elapsedMs: performance.now() - startedAt,
batches: 1,
};
}Depending on your hardware, you should be able to notice that animated ball 'freezes' briefly when you run unoptimized version of the code. Animation remains smooth when we execute the optimized version. You can see total time it took to run the whole processing rendered in the example as well.
Both implementations return the same shape:
type RunResult = {
mode: RunMode;
seed: number;
values: Uint32Array;
elapsedMs: number;
batches: number;
};In reality your 'RunResult' will likely be just data you want to produce. Here we put mode, values, batches and elapsedMs for the sake of adding extra visuals to the demo.
Main difference and cost of this pattern: what was once a synchronous task now must be awaited. Depending on your data structures, it may also get a bit tricky to come up with a way to batch and accumulate processing result over time (think trees and graph transformations).
In practice added complexity and slightly longer overall computation time is well worth it - using this pattern often means difference between glitching and responsive UI.
When This Fits
Async yielding is a good fit when:
- Your problem is localized to specific widget or area of code
- It is easy to split the work into small steps
- Partial progress can be stored between batches
This is not a one-size-fits-all kind of thing. Often performance problem lies in rendering, or your code is fine and third-party library is inefficient. Another good solution is often to return a subset of data by aggregating or filtering it on the API side.
Other Options Worth Considering
Async yielding is more of a local fix. When it is not enough, these are options I would consider next:
- Web Workers: move CPU-heavy work to another thread. Pros: powerfule, solves performance problem on a large scale; Cons: you need message protocol, serialization, error handling.
- Virtualization: render only the visible part of a long list, table, tree, or grid. This helps when the bottleneck is rendering UI.
- Server-side preprocessing: Typically the best solution, although not always available.
- Library replacement or targeted optimization: profile third-party code to pinpoint the issue. Sometimes the fastest fix is avoiding an inefficient abstraction.
Conclusion
Async yielding is a practical and easy fix for localized problems. For standard codebases it is usually a low-investment fix that can be replaced or reverted with little effort.
Biggest complexity usually revolves around structuring your computation in a way that allows it to be paused and resumed later.
Start with profiling, confirm that problem is slow processing, than assess if it is chunkable. Try async yielding with a time budget of 4-8 ms. If same problem affects many areas of the codebase, investing in web workers becomes justifyable and may solve the problem long-term.