Pourquoi le scheduling devient important côté frontend

Une interface web moderne ne se contente plus d’afficher quelques blocs de texte. Elle valide des formulaires, synchronise des données, prépare des animations, traite des listes volumineuses, mesure la performance, précharge des ressources et réagit aux actions utilisateur en continu.

Le problème est que JavaScript s’exécute principalement sur le thread principal du navigateur. C’est ce même thread qui gère aussi le rendu, les interactions, le calcul des styles et une partie de la mise en page. Si vous lancez une tâche JavaScript trop longue, l’interface peut devenir temporairement figée : clics ignorés, saisie ralentie, animation saccadée.

On peut déjà déplacer certains calculs dans un worker, comme expliqué dans l’article sur les Web Workers. Mais toutes les tâches ne méritent pas un worker. Certaines sont liées au DOM, à l’état d’interface ou à de petits traitements applicatifs. C’est là que la Scheduler API devient intéressante.

Ce que permet la Scheduler API

La Scheduler API expose notamment scheduler.postTask(), une méthode qui permet de planifier une tâche avec une priorité. L’idée n’est pas de rendre JavaScript multithread, mais de mieux coopérer avec la boucle d’événements du navigateur.

Au lieu d’exécuter immédiatement une opération non critique, vous pouvez dire : cette tâche peut attendre, elle est en arrière-plan. À l’inverse, une tâche qui répond directement à une interaction utilisateur peut recevoir une priorité plus élevée.

Les priorités principales sont généralement :

  • user-blocking pour les tâches nécessaires à une interaction immédiate ;
  • user-visible pour les tâches visibles par l’utilisateur mais moins urgentes ;
  • background pour les traitements non critiques.

Un exemple minimal :

await scheduler.postTask(() => {
  console.log('Tâche exécutée quand le navigateur peut la traiter')
}, {
  priority: 'background'
})

Ce code indique que le traitement peut être différé. Le navigateur peut ainsi privilégier des opérations plus urgentes, comme une interaction utilisateur ou une mise à jour visuelle.

Un cas concret : filtrer une grande liste

Imaginons une interface qui filtre plusieurs milliers d’éléments après une saisie. Une version naïve peut rapidement bloquer l’interface si le traitement est coûteux.

type Product = {
  id: string
  name: string
  description: string
  tags: string[]
}

function searchProducts(products: Product[], query: string) {
  const normalizedQuery = query.trim().toLowerCase()

  return products.filter((product) => {
    return (
      product.name.toLowerCase().includes(normalizedQuery) ||
      product.description.toLowerCase().includes(normalizedQuery) ||
      product.tags.some((tag) => tag.toLowerCase().includes(normalizedQuery))
    )
  })
}

Cette fonction est correcte, mais si elle s’exécute à chaque frappe sur une liste importante, elle peut dégrader l’expérience. On peut d’abord combiner plusieurs techniques : debounce, annulation avec AbortController, cache applicatif et scheduling.

L’annulation est particulièrement utile lorsque plusieurs recherches se succèdent rapidement, comme expliqué dans l’article sur AbortController.

Créer une fonction de scheduling réutilisable

Comme toute API web récente, il est préférable d’encapsuler son usage. Cela permet de prévoir un fallback si scheduler.postTask n’est pas disponible.

type TaskPriority = 'user-blocking' | 'user-visible' | 'background'

type PostTaskOptions = {
  priority?: TaskPriority
  signal?: AbortSignal
}

declare global {
  interface Window {
    scheduler?: {
      postTask<T>(
        callback: () => T | Promise<T>,
        options?: PostTaskOptions
      ): Promise<T>
    }
  }
}

export async function postTask<T>(
  callback: () => T | Promise<T>,
  options: PostTaskOptions = {}
): Promise<T> {
  if (window.scheduler?.postTask) {
    return window.scheduler.postTask(callback, options)
  }

  if (options.signal?.aborted) {
    throw new DOMException('Task aborted', 'AbortError')
  }

  return new Promise<T>((resolve, reject) => {
    const timeoutId = window.setTimeout(async () => {
      try {
        if (options.signal?.aborted) {
          reject(new DOMException('Task aborted', 'AbortError'))
          return
        }

        resolve(await callback())
      } catch (error) {
        reject(error)
      }
    }, 0)

    options.signal?.addEventListener('abort', () => {
      window.clearTimeout(timeoutId)
      reject(new DOMException('Task aborted', 'AbortError'))
    }, { once: true })
  })
}

Ce wrapper a plusieurs avantages. Le code applicatif ne dépend pas directement de l’API native, le fallback reste prévisible, et les signaux d’annulation sont pris en compte.

Planifier une recherche non bloquante

On peut maintenant utiliser cette fonction pour différer une recherche coûteuse. La tâche reste sur le thread principal, mais elle devient moins agressive pour l’interface.

import { postTask } from './post-task'

export async function scheduledSearch(
  products: Product[],
  query: string,
  signal: AbortSignal
) {
  return postTask(() => searchProducts(products, query), {
    priority: 'user-visible',
    signal
  })
}

Dans un composant React, cela peut être combiné avec un nettoyage d’effet :

import { useEffect, useState } from 'react'

function ProductSearch({ products }: { products: Product[] }) {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<Product[]>(products)
  const [isPending, setIsPending] = useState(false)

  useEffect(() => {
    const controller = new AbortController()

    async function runSearch() {
      setIsPending(true)

      try {
        const nextResults = await scheduledSearch(
          products,
          query,
          controller.signal
        )

        setResults(nextResults)
      } catch (error) {
        if (!(error instanceof DOMException && error.name === 'AbortError')) {
          console.error(error)
        }
      } finally {
        if (!controller.signal.aborted) {
          setIsPending(false)
        }
      }
    }

    runSearch()

    return () => {
      controller.abort()
    }
  }, [products, query])

  return (
    <section>
      <label htmlFor="product-search">Rechercher un produit</label>
      <input
        id="product-search"
        value={query}
        onChange={(event) => setQuery(event.target.value)}
      />

      {isPending && <p>Recherche en cours...</p>}

      <ul>
        {results.map((product) => (
          <li key={product.id}>{product.name}</li>
        ))}
      </ul>
    </section>
  )
}

Ce composant reste volontairement simple. Dans une vraie application, on pourrait ajouter un debounce, une virtualisation de liste ou un cache local. Si les résultats proviennent d’une API, les stratégies décrites dans l’article sur le cache frontend stale-while-revalidate deviennent complémentaires.

Choisir la bonne priorité

La priorité ne doit pas être utilisée comme un décor. Elle exprime une intention d’architecture.

Une tâche user-blocking doit être rare. Elle concerne ce qui est indispensable pour répondre directement à l’utilisateur : ouvrir un panneau, appliquer une action critique, finaliser une transition d’état.

Une tâche user-visible convient mieux aux mises à jour qui vont être vues rapidement, mais qui ne doivent pas monopoliser l’exécution : filtrage, préparation d’un rendu secondaire, calcul d’un résumé affiché dans l’interface.

Une tâche background est adaptée aux opérations discrètes : pré-calcul, indexation locale, nettoyage de cache, collecte de métriques non urgente ou synchronisation différée.

await postTask(() => cleanExpiredCacheEntries(), {
  priority: 'background'
})

await postTask(() => computeVisibleRecommendations(), {
  priority: 'user-visible'
})

Pour les tâches de mesure, la Scheduler API peut aussi compléter une démarche d’observabilité. Vous pouvez par exemple collecter certaines métriques sans perturber l’expérience principale, en cohérence avec les principes abordés dans l’article sur PerformanceObserver.

Scheduler API ou Web Worker ?

La Scheduler API ne remplace pas les Web Workers. Elle aide à organiser le travail sur le thread principal. Si votre traitement est très coûteux, purement algorithmique et indépendant du DOM, un worker reste généralement plus adapté.

Utilisez plutôt la Scheduler API lorsque :

  • la tâche est courte ou moyenne ;
  • elle dépend de l’état d’interface ;
  • elle doit accéder au DOM ou à des APIs disponibles côté fenêtre ;
  • vous voulez prioriser plusieurs traitements applicatifs ;
  • vous cherchez une amélioration progressive sans changer toute l’architecture.

Utilisez plutôt un Web Worker lorsque :

  • le calcul peut durer longtemps ;
  • l’interface doit rester fluide pendant le traitement ;
  • les données peuvent être sérialisées ou transférées proprement ;
  • le traitement est indépendant du rendu.

Dans certains cas, les deux approches peuvent coexister : le thread principal planifie les étapes légères, tandis que le worker exécute les calculs lourds.

Découper les longues tâches

Planifier une tâche ne suffit pas si cette tâche exécute ensuite une boucle énorme sans pause. Pour améliorer réellement la réactivité, il faut parfois découper le travail.

export async function processInChunks<T>(
  items: T[],
  processItem: (item: T) => void,
  signal: AbortSignal
) {
  const chunkSize = 100

  for (let index = 0; index < items.length; index += chunkSize) {
    if (signal.aborted) {
      throw new DOMException('Processing aborted', 'AbortError')
    }

    const chunk = items.slice(index, index + chunkSize)

    await postTask(() => {
      for (const item of chunk) {
        processItem(item)
      }
    }, {
      priority: 'background',
      signal
    })
  }
}

Ce modèle est utile pour indexer des données locales, recalculer des métadonnées ou préparer une recherche côté client. Pour du stockage local structuré, il peut être combiné avec IndexedDB en TypeScript.

Bonnes pratiques

La Scheduler API doit rester un outil de précision. Évitez de placer toutes vos fonctions dans postTask() sans réflexion. Cela rendrait le comportement plus difficile à lire sans forcément améliorer la performance.

Mesurez avant et après. Les longues tâches, les interactions lentes et les blocages de rendu doivent être observés avec des outils concrets. Une optimisation de scheduling est pertinente si elle améliore la réactivité perçue ou réduit les blocages visibles.

Prévoyez également l’annulation. Une tâche différée peut devenir inutile avant d’être exécutée : changement de page, nouvelle saisie, fermeture d’un panneau, données obsolètes. Associer scheduler.postTask() à AbortSignal évite d’appliquer des résultats périmés.

Enfin, gardez une stratégie de progressive enhancement. Le site doit rester fonctionnel sans cette API. Le fallback à setTimeout() ne reproduit pas exactement le comportement natif, mais il permet de conserver une architecture stable.

Conclusion

La Scheduler API apporte une nuance importante au développement frontend : toutes les tâches JavaScript n’ont pas la même urgence. En exprimant cette priorité, vous aidez le navigateur à préserver la fluidité de l’interface.

Elle ne remplace ni les Web Workers, ni l’optimisation algorithmique, ni la mesure de performance. Mais utilisée avec discernement, elle devient un excellent outil d’architecture frontend pour traiter les opérations secondaires, découper les longues tâches et éviter de transformer chaque interaction en point de blocage.