DEV Community

Gil Fink
Gil Fink

Posted on • Originally published at gilfink.Medium on

Beyond the Basics: Building a Responsive Image Processing Pipeline with the Scheduler API

Following up on our discussion about mastering task management in JavaScript for smoother UIs, this post will take us deeper into the practical application of the Scheduler API. We’ve seen how postTask() and yield() can combat "janky UIs" by intelligently prioritizing and breaking down operations. Now, let's explore a real-world scenario where these capabilities truly shine: creating a responsive image processing pipeline directly in the browser.

The Challenge: Client-Side Image Processing and UI Responsiveness

Client-side image processing, such as resizing, applying filters, or watermarking, can be incredibly resource-intensive. If not handled carefully, these operations can easily block the main thread, leading to frozen UIs and frustrated users. Imagine a web application where users upload and manipulate high-resolution images — without proper task management, this could quickly become a nightmare.

The Solution: Orchestrating Tasks with the Scheduler API

There are a lot of solutions in the internet that uses Web Workers for image processing task, but this time we are going to leverage the new Scheduler API. The Scheduler API provides the perfect toolkit to manage these computationally heavy tasks without sacrificing responsiveness. We can break down the image processing workflow into smaller, manageable units and assign them appropriate priorities, ensuring that user interactions always take precedence.

A Browser-Based Image Editor

Let’s consider a simplified image editor where a user can upload an image, apply a grayscale filter, and then resize it for display. Here’s how we can use the Scheduler API to keep the UI responsive throughout this process:

1. User Interaction and Immediate Feedback (User-Blocking Priority)

When a user clicks to upload an image or apply a filter, the immediate visual feedback (e.g., a loading spinner, a modal window) is essential. These tasks should be user-blocking:

async function handleImageUpload(event) {
  displayLoadingSpinner();
  await scheduler.postTask(() => {
    // Simulate a quick file read
    console.log("Image upload started: User-Blocking task");
    // ... actual file reading logic
  }, { priority: 'user-blocking' });
  // Now, transition to user-visible for processing
  processImage(event.target.files[0]);
}
Enter fullscreen mode Exit fullscreen mode

2. Image Processing (User-Visible Priority)

The actual image processing (applying a filter, resizing) can be broken down. While important for the user to see the result, these operations can yield periodically to allow the UI to remain interactive. We'll use user-visible priority for these tasks:

async function processImage(file) {
  hideLoadingSpinner();
  displayProcessingIndicator();
  const image = await createImageBitmap(file);
  // Apply Grayscale Filter 
  await scheduler.postTask(async () => {
    console.log("Applying grayscale filter: User-Visible task");
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    canvas.width = image.width;
    canvas.height = image.height;
    ctx.drawImage(image, 0, 0);
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    const pixels = imageData.data;
    for (let i = 0; i < pixels.length; i += 4) {
      const avg = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3;
      pixels[i] = avg; // Red
      pixels[i + 1] = avg; // Green
      pixels[i + 2] = avg; // Blue
      // Yield periodically to prevent UI jank
      if (i % 10000 === 0) { // Yield every 10,000 pixels (adjust as needed)
        await scheduler.yield();
        console.log("Yielded during grayscale processing");
      }
    }
    ctx.putImageData(imageData, 0, 0);
    console.log("Grayscale filter applied.");
  }, { priority: 'user-visible' });

  await scheduler.postTask(async () => {
    console.log("Resizing image: User-Visible task");
    const resizedCanvas = document.createElement('canvas');
    const resizedCtx = resizedCanvas.getContext('2d');
    const targetWidth = 400; 
    const targetHeight = (image.height / image.width) * targetWidth;
    resizedCanvas.width = targetWidth;
    resizedCanvas.height = targetHeight;
    resizedCtx.drawImage(image, 0, 0, targetWidth, targetHeight);
    console.log("Image resized.");
    displayProcessedImage(resizedCanvas.toDataURL());
  }, { priority: 'user-visible' });
  hideProcessingIndicator();
}
Enter fullscreen mode Exit fullscreen mode

3. Background Operations (Background Priority)

Perhaps after processing, we want to save a smaller version of the image to local storage, or perform other non-critical operations. These can be handled with background priority:

async function cacheImage(imageDataURL) {
  await scheduler.postTask(() => {
    console.log("Caching image to local storage: Background task");
    // Simulate caching
    // localStorage.setItem('processedImage', imageDataURL);
  }, { priority: 'background' });
}
Enter fullscreen mode Exit fullscreen mode

Some Considerations:

  • Granularity of Tasks: The key is to break down large operations into small enough chunks to allow yield() to be effective. Experiment with the if (i % 10000 === 0) condition in the grayscale example to find the optimal balance for your specific tasks.
  • Avoiding Over-Yielding: While yielding is good, yielding too frequently can introduce its own overhead. Find the sweet spot where the UI remains responsive without degrading overall performance.

Summary

By strategically utilizing postTask() with different priorities and judiciously employing yield() within long-running computations, we can construct highly responsive web applications, even when dealing with demanding tasks like client-side image processing. The Scheduler API empowers us to create seamless UX, ensuring that our JavaScript applications are not just functional, but truly delightful to interact with.

Top comments (0)