Skip to content
ExponaBLOG
TECHNICAL SEO FOR DEVELOPERS · DAY 045 / 100

Break up long tasks: a Technical SEO guide for developers

Anything over 50ms blocks interactions.

Expona Admin7 min read

Interactions should complete within 100ms so users feel the system is instant. When a single JavaScript task blocks the main thread for more than 50ms, it degrades perceived performance and harms your Core Web Vitals—particularly Interaction to Next Paint (INP). Break up long tasks: a Technical SEO guide for developers shows you how to chunk expensive operations and yield control back to the browser between chunks, keeping your site responsive even during heavy computation.

Why 50ms matters for long tasks

The main thread handles user input, rendering, and JavaScript execution. When a single task runs for more than 50ms, the browser cannot begin processing the next user interaction until that task completes. A keystroke, tap, or click gets queued behind your long-running code.

Here's the math: if a user presses a key and your JavaScript task has 75ms remaining, the browser must wait 75ms before it can even start responding to the keystroke. That keystroke response is now part of the INP metric, and you've already missed the 100ms responsiveness window.

Breaking up long tasks into smaller chunks—each under 50ms—means the browser gets breathing room. Between chunks, it can:

  • Process user input immediately
  • Execute scheduled rendering updates
  • Run other queued microtasks
  • Fire timers and network callbacks

The result: your application feels snappy, and your INP score improves.

Yield to the main thread between chunks

The core pattern for breaking up long tasks is simple: execute a small chunk of work, then yield control back to the browser before continuing with the next chunk.

A basic implementation uses setTimeout to schedule the next chunk:

html
function processLargeDataset(data, callback) {
  let index = 0;
  const chunkSize = 100; // Process 100 items per chunk

  function processChunk() {
    const end = Math.min(index + chunkSize, data.length);

    for (let i = index; i < end; i++) {
      // Do expensive work on data[i]
      const result = expensiveCalculation(data[i]);
      callback(result);
    }

    index = end;

    if (index < data.length) {
      // Yield to main thread before next chunk
      setTimeout(processChunk, 0);
    }
  }

  processChunk();
}

// Usage: Import CSV, process rows without blocking
processLargeDataset(rows, (result) => {
  updateUI(result);
});

With setTimeout(..., 0), the browser gets a chance to handle input and render between iterations. On a large dataset (10,000 rows, for example), this approach prevents a multi-second freeze.

The tradeoff: processing takes longer overall because of the scheduling overhead. In the example above, a task that might take 200ms without yielding could take 400–500ms when split across many chunks. But the user perceives the interface as responsive the whole time, which matters more than wall-clock speed for perceived performance.

Use scheduler.yield() where supported

Modern browsers now offer scheduler.yield(), a cleaner way to hand control back to the browser. Instead of wrapping everything in setTimeout callbacks, you can use async/await:

html
async function processLargeDatasetModern(data, callback) {
  const chunkSize = 100;

  for (let i = 0; i < data.length; i += chunkSize) {
    const end = Math.min(i + chunkSize, data.length);

    for (let j = i; j < end; j++) {
      const result = expensiveCalculation(data[j]);
      callback(result);
    }

    // Yield to browser between chunks
    await scheduler.yield();
  }
}

// Usage
await processLargeDatasetModern(rows, updateUI);

scheduler.yield() is part of the Prioritized Task Scheduling API and tells the browser: "I'm done with this chunk; process user input and other high-priority work before resuming me." It's semantically clearer than setTimeout and integrates with the browser's scheduling system.

Check browser support (Chrome 94+, Edge 94+, Safari 16.4+, Firefox support pending). For older browsers, fall back to setTimeout:

html
async function yieldToMain() {
  if ('scheduler' in window && 'yield' in window.scheduler) {
    await scheduler.yield();
  } else {
    return new Promise(resolve => setTimeout(resolve, 0));
  }
}

async function processLargeDataset(data, callback) {
  const chunkSize = 100;

  for (let i = 0; i < data.length; i += chunkSize) {
    const end = Math.min(i + chunkSize, data.length);

    for (let j = i; j < end; j++) {
      callback(expensiveCalculation(data[j]));
    }

    await yieldToMain();
  }
}

Move heavy work to web workers

Sometimes chunking and yielding still isn't enough. If a single chunk of work takes 40ms and you have many other tasks competing for the main thread, even a yielding approach can degrade responsiveness.

Web Workers run JavaScript on a separate thread, completely off the main thread. They're ideal for:

  • Image processing and filters
  • Large data transformations (sorting, grouping, filtering)
  • Cryptographic operations
  • PDF or video encoding
  • Machine learning inference

A real-world example: filtering and sorting a list of 50,000 products by price, rating, and availability. This task is CPU-bound and can easily block the main thread for 200–300ms on slower devices.

html
// main.js
async function filterAndSortProducts(products, filters) {
  return new Promise((resolve) => {
    const worker = new Worker('filter-worker.js');

    worker.postMessage({ products, filters });

    worker.onmessage = (event) => {
      resolve(event.data);
      worker.terminate();
    };
  });
}

// Usage
const results = await filterAndSortProducts(allProducts, {
  minPrice: 50,
  maxPrice: 500,
  minRating: 4.0,
});
html
// filter-worker.js
self.onmessage = (event) => {
  const { products, filters } = event.data;

  let filtered = products.filter(p =>
    p.price >= filters.minPrice &&
    p.price <= filters.maxPrice &&
    p.rating >= filters.minRating
  );

  filtered.sort((a, b) => b.rating - a.rating);

  self.postMessage(filtered);
};

The main thread remains free to handle user interaction while the worker processes data in parallel. This is especially valuable on mobile devices with slower CPUs.

Tradeoffs: there's overhead in copying data between threads (structured cloning), and you can't manipulate the DOM from a worker. Use workers for pure computation, not for UI updates.

Defer work users do not need yet

Not all work is equally urgent. Users don't always need all features immediately. Deferring non-critical work until after the user has interacted reduces the initial load on the main thread.

Common deferral patterns:

  1. Lazy-load below-the-fold content. Don't parse and render product images or reviews until the user scrolls near them.
  2. Defer analytics and telemetry. Send performance metrics and user tracking after the page is interactive, not during initial load.
  3. Defer third-party scripts. Load ads, chat widgets, and tracking pixels after the page is stable.
  4. Defer non-critical data processing. If a data table has 10,000 rows but the user initially sees 50, compute statistics or generate exports on-demand.

Example: a product page that loads reviews on scroll:

html
let reviewsLoaded = false;
const reviewSection = document.querySelector('[data-reviews]');

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting && !reviewsLoaded) {
      loadReviews();
      reviewsLoaded = true;
    }
  });
}, { rootMargin: '50px' });

observer.observe(reviewSection);

async function loadReviews() {
  const response = await fetch('/api/reviews');
  const reviews = await response.json();

  // Render reviews—this won't block initial interaction
  renderReviews(reviews);
}

By deferring the reviews API call and rendering until the user scrolls nearby, the initial page interaction completes faster and INP improves.

Common mistakes

Mistake 1: Chunking too large. If each chunk takes 45ms but you have five chunks back-to-back with no yielding between them, you've just created a 225ms long task. Always measure and ensure each chunk stays under 50ms with yielding between them.

Mistake 2: Over-engineering with workers. Web Workers add complexity and overhead. Don't use a worker for a 5ms task; the cost of structured cloning and thread overhead will make it slower. Reserve workers for genuinely expensive operations (50ms+).

Mistake 3: Deferring too aggressively. If you defer a task the user needs immediately (like loading the search input autocomplete data), you've moved responsiveness forward but created frustration. Defer work that enhances the experience, not work that blocks core functionality.

Mistake 4: Ignoring browser support. scheduler.yield() is new. Always have a fallback, and test on older devices. A polyfill or conditional check prevents your interaction logic from silently failing.

Key takeaways

  • Tasks over 50ms block the main thread and hurt INP. Break them into smaller chunks and yield between chunks so the browser can handle user input.
  • Use scheduler.yield() with a setTimeout fallback for cleaner, more maintainable yielding code.
  • Move CPU-intensive work (filtering, sorting, transformations) to Web Workers to keep the main thread free for interaction.
  • Defer non-critical work—analytics, third-party scripts, below-the-fold content—until after the page is interactive.
  • Measure your task duration in DevTools and adjust chunk size accordingly. What's fast on your development machine may be slow on a median mobile device.
  • Balance developer experience and maintainability. Chunking adds code; make sure the performance gain justifies the complexity.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles