Learnitweb

Handling Long-Running Tasks in the Browser Without Freezing the Page

In the previous tutorial, you learned that modern browsers use an event loop, multiple task queues, and a rendering pipeline that typically runs every sixteen milliseconds in order to maintain smooth animations and responsive user interactions. You also learned that if a JavaScript task takes longer than those sixteen milliseconds to complete, the browser is unable to update the screen during that time, which results in visible lag, frozen interactions, and an overall poor user experience.

In this tutorial, we will take that theoretical understanding one step further and see how long-running tasks actually affect a real web page. More importantly, we will learn how to restructure such tasks so that the browser remains responsive, even when a large amount of computation is required.

Understanding the Problem Through a Practical Example

Imagine a scenario where your application needs to perform a heavy calculation as part of a business requirement. For example, you may need to process a very large list of numbers and calculate the square root of each value. This is not an unusual situation, as many applications perform data processing, analytics, or transformations directly in the browser.

To demonstrate this problem clearly, let us imagine a simple web page containing the following elements:

  • Two buttons that trigger different implementations of the same task
  • A text area that allows user interaction
  • A small area on the page that displays progress information

The purpose of these elements is not visual design, but rather to clearly observe how the browser behaves when heavy computations are running.

The First Approach: A Straightforward but Problematic Solution

In the first solution, we implement the most intuitive approach. We generate a large array of numbers and then process each number one by one by calculating its square root.

The overall logic looks like this:

  1. Generate a large array of numbers.
  2. Iterate through the entire array.
  3. For each number, calculate its square root.
  4. Store the result in another array.
  5. Update the UI to show how many items remain to be processed.

From a purely logical standpoint, this solution is perfectly valid. It fulfills the business requirement and produces the correct output. However, it introduces a serious performance problem.

When this function starts running, it processes all numbers in a single execution block. Because JavaScript is single-threaded, this means the browser cannot do anything else until the function finishes executing.

When you click the button that triggers this logic, several things become immediately noticeable:

  • The button appears frozen and does not visually respond to the click.
  • You cannot select text inside the text area.
  • Other buttons on the page become unresponsive.
  • The progress indicator does not update gradually and instead jumps directly to the final value once the task finishes.

This happens because the rendering pipeline cannot run while JavaScript is busy executing a long task. As long as the call stack is occupied, the browser cannot repaint the UI or respond to user input.

Once the task finally completes, everything suddenly becomes responsive again, which clearly demonstrates how disruptive long-running tasks can be to user experience.

Why This Happens Internally

Even if the browser needs to repaint the screen or handle user input, it must wait until the currently executing task finishes. This means that any task that takes longer than approximately sixteen milliseconds will block rendering and cause visible performance issues.

In our case, iterating over a large dataset and performing calculations synchronously easily exceeds this time limit.

Improving the Solution by Splitting Work Into Smaller Tasks

To fix this problem, we need to rethink how the work is performed. Instead of processing the entire dataset in a single task, we can split the workload into multiple smaller tasks and allow the browser to process them gradually.

The key idea is to divide the input data into smaller batches and process one batch at a time. After each batch is processed, we allow the browser to regain control so it can update the UI, respond to user interactions, and perform rendering if necessary.

This approach transforms one large blocking task into many small, manageable tasks.

Implementing the Improved Solution

In the improved version, we still generate the same list of numbers and still calculate their square roots. However, instead of processing the entire list in one go, we do the following:

  1. Divide the input array into smaller batches of fixed size, such as fifty thousand items per batch.
  2. Process only one batch at a time.
  3. After finishing a batch, schedule the next batch using setTimeout with a delay of zero.
  4. Continue this process until all items have been processed.

Even though setTimeout is used with a delay of zero milliseconds, it still creates a new task in the task queue. This is crucial because it allows the JavaScript engine to return control to the event loop between batches.

As a result, the browser can repaint the page, respond to clicks, and handle user interactions between each batch of work.

Why This Approach Works So Well

At first glance, this technique may seem counterintuitive. It might feel unnecessary to split work into smaller pieces when everything could technically be done in one function call.

However, this approach works because JavaScript executes each task atomically. Once a task begins, it must complete before anything else can happen. By splitting one large task into multiple smaller tasks, we give the browser frequent opportunities to perform rendering and event handling.

This allows:

  • The UI to remain responsive
  • Buttons to visually react to user interaction
  • Text to be selected or edited while computation is ongoing
  • Progress information to update smoothly

In other words, the browser is no longer blocked by long-running JavaScript execution.

Observing the Improved Behavior in the Browser

When you run the improved version and click the second button, the behavior is noticeably different:

  • The button reacts immediately when clicked.
  • You can interact with the text area while processing is ongoing.
  • The progress indicator updates continuously instead of freezing.
  • The page remains responsive throughout the operation.

Once all items are processed, the progress indicator reaches zero, and the task completes naturally without freezing the interface.

This creates a significantly better user experience and demonstrates the importance of structuring long-running tasks correctly.

Final Thoughts

Long-running tasks are one of the most common causes of performance issues in browser-based applications. When such tasks block the main thread, they prevent rendering, user interaction, and event handling, leading to a poor user experience.

By breaking large tasks into smaller units and scheduling them using mechanisms such as setTimeout, you allow the browser to interleave computation with rendering and user interaction. This simple structural change can dramatically improve responsiveness without changing the underlying logic of your application.

In the next part of this series, you will explore even more advanced techniques for handling heavy workloads efficiently, including approaches that move computation off the main thread entirely.