Destino Dev

Blog

Scheduler cooperativo no browser com MessageChannel e requestIdleCallback

Por John Pires · 13 de março de 2026

Scheduler cooperativo no browser com MessageChannel e requestIdleCallback

Problema real que resolve

Sintoma no UI

Aplicações web modernas frequentemente precisam processar listas grandes, renderizar diffs, validar dados ou executar cálculos pesados no cliente. Quando essa carga é executada de forma contínua em um único frame, a UI congela: cliques não respondem, animações salteiam e o usuário percebe lentidão. Esse é um problema clássico causado por tarefas longas bloqueando o event loop.

O sintoma típico é simples de reproduzir: um loop que processa milhares de itens sem yield produz frames acima de 16ms, e a experiência degrada imediatamente. Soluções que apenas dividem em setTimeout/Promise nem sempre são suficientes, pois podem introduzir jitter ou trabalhar no momento errado do ciclo de renderização.

Por que o event loop sozinho não basta

O event loop do JavaScript é correto, mas não tem noção de prioridade entre tarefas do aplicativo: timers, eventos de input, render, microtasks e tasks convivem conforme especificação. Para manter a UI responsiva precisamos cooperar com o navegador, permitindo que ele faça trabalho crítico (render, input) entre fatias de execução do nosso processamento.

Além disso, temos múltiplos contextos: requestAnimationFrame (rAF) vincula-se ao frame render, Promise.then roda como microtask, setTimeout como macrotask. Na prática, precisamos de uma estratégia determinística para ceder a execução de forma útil — por exemplo, interromper trabalho quando o navegador precisa renderizar ou quando o time-slice esgotou.

Abordagem ingênua vs abordagem otimizada

Abordagem ingênua

As abordagens comuns e ingênuas incluem dividir uma grande tarefa com setTimeout(..., 0) ou usar Promise.resolve().then(). Elas têm problemas práticos:

  • setTimeout introduz atraso desnecessário e é menos previsível;
  • Promise.then roda como microtask e pode congestionar o loop se enfileirado recursivamente;
  • requestAnimationFrame está preso ao ritmo de frames e não é ideal para tarefas de baixa prioridade.

Essas técnicas funcionam, mas frequentemente exigem ajuste fino e têm problemas em dispositivos lentos ou com carga variada. Elas também não expõem controle de prioridade ou tempo de execução por fatia.

Abordagem otimizada: cooperativa com MessageChannel + requestIdleCallback

Uma abordagem robusta combina duas Web APIs: MessageChannel para enfileirar micro-tasks macrotarefa-like de baixa latência e requestIdleCallback para executar trabalho quando o navegador estiver ocioso. A ideia é:

  1. usar MessageChannel para agendar a próxima fatia de execução com baixa latência;
  2. usar requestIdleCallback quando disponível para aproveitar os tempos ociosos;
  3. fornecer controle de duração por fatia (time-slicing) e prioridade;
  4. permitir cancelamento e monitoramento para testes.

Essa combinação permite que o scheduler ceda o controle de forma cooperativa e previsível, reduzindo blocking e mantendo a UI responsiva.

Implementação com código real

API pública do scheduler

Vamos definir uma API minimal e prática: schedule(callback, options) retorna um token/cancel function; options inclui priority e maxYield (ms). O callback pode ser síncrono ou retornar uma Promise. O scheduler fatiará a execução chamando callbacks repetidamente até que a lista de tarefas acabe ou até que o time-slice local expire, então reagendará via MessageChannel ou requestIdleCallback.

Antes do código, coloque atenção em dois pontos: (1) medir tempo com performance.now() e (2) fornecer fallback para ambientes que não suportam requestIdleCallback.

O próximo bloco implementa o scheduler completo. Ele é auto-contido e funcional em navegadores modernos e também fornece fallback.

/* Scheduler cooperativo com MessageChannel + requestIdleCallback (ES2024+) */
function createCooperativeScheduler() {
  const queue = [];
  let scheduled = false;
  let channel;

  // Fallback para requestIdleCallback
  const ric = typeof requestIdleCallback === 'function'
    ? requestIdleCallback
    : function (cb) { return setTimeout(() => cb({ timeRemaining: () => 50 }), 1); };
  const ricCancel = typeof cancelIdleCallback === 'function'
    ? cancelIdleCallback
    : clearTimeout;

  function ensureChannel() {
    if (channel) return channel;
    channel = new MessageChannel();
    channel.port1.onmessage = () => runLoop();
    return channel;
  }

  function scheduleRun() {
    if (scheduled) return;
    scheduled = true;
    const ch = ensureChannel();
    // PostMessage é preferível a setTimeout por menor latência
    ch.port2.postMessage(undefined);
  }

  function runLoop(deadline) {
    scheduled = false;
    const sliceStart = performance.now();
    const maxSlice = (deadline && typeof deadline.timeRemaining === 'function')
      ? Math.max(0, Math.min(50, deadline.timeRemaining())) // usar idle time quando disponível
      : 10; // default conservador

    while (queue.length) {
      const task = queue.shift();
      try {
        const res = task.fn();
        if (res && typeof res.then === 'function') {
          // se retornar promise, espera mas não bloqueia loop
          res.catch(() => {});
        }
      } catch (err) {
        task.reject && task.reject(err);
      }

      // Se tempo esgotou, reagenda usando requestIdleCallback para aproveitar idle
      if (performance.now() - sliceStart > maxSlice) {
        ric((dl) => runLoop(dl));
        return;
      }
    }
  }

  function schedule(fn, options = {}) {
    return new Promise((resolve, reject) => {
      const task = {
        fn: () => {
          try {
            const r = fn();
            resolve(r);
            return r;
          } catch (err) {
            reject(err);
            throw err;
          }
        },
        reject
      };
      if (options.priority === 'high') {
        queue.unshift(task);
      } else {
        queue.push(task);
      }
      scheduleRun();
    });
  }

  function cancelAll() {
    queue.length = 0;
    scheduled = false;
    if (channel) channel.port1.onmessage = null;
  }

  return { schedule, cancelAll };
}

Depois desse bloco: o scheduler acima cria uma fila simples, usa MessageChannel para latência baixa e tenta usar requestIdleCallback quando a fatia esgota. Ele resolve uma Promise quando a tarefa é executada, e a API permite prioridade simples.

Uso prático: processar um array grande sem travar o UI

Contexto: você tem 100k itens para processar (por exemplo, sanitizar ou computar métricas) e quer atualizar um contador visual a cada N itens sem bloquear.

// Exemplo de uso real: processar grande lista em fatias
const scheduler = createCooperativeScheduler();

function processChunked(items, onProgress) {
  let i = 0;
  return new Promise((resolve) => {
    function work() {
      const batch = 200; // items por iteração
      const end = Math.min(i + batch, items.length);
      for (; i < end; i++) {
        // trabalho pesado simulado
        items[i] = items[i] * 2; // operação representativa
      }
      onProgress(i / items.length);
      if (i < items.length) {
        // reenqueue
        return scheduler.schedule(work);
      }
      resolve(items);
    }

    scheduler.schedule(work);
  });
}

// Uso
const data = new Array(100000).fill(0).map((_, idx) => idx);
processChunked(data, (p) => console.log('progress', Math.round(p * 100) + '%'))
  .then(() => console.log('done'));

Depois do bloco: esse padrão divide o trabalho em batches e usa o scheduler para garantir que a UI possa renderizar entre as fatias. O progresso é reportado de forma contínua.

Cancelamento e promise de cancel

Contexto: às vezes você precisa cancelar processamento (navegação, busca nova). O scheduler anterior tem cancelAll, mas podemos melhorar retornando um token de cancelamento por tarefa.

// Extensão: agendar com token de cancelamento
function createCancelableScheduler() {
  const base = createCooperativeScheduler();

  function scheduleCancelable(fn, opts = {}) {
    let cancelled = false;
    const token = { cancel: () => { cancelled = true; } };
    const wrapped = () => {
      if (cancelled) return undefined;
      return fn();
    };
    const p = base.schedule(wrapped, opts);
    return { p, token };
  }

  return Object.assign({}, base, { scheduleCancelable });
}

// Uso
const cs = createCancelableScheduler();
const { p, token } = cs.scheduleCancelable(() => heavyWork());
// Em navegação: token.cancel();

Depois do bloco: o pattern de token simples permite cancelar tarefas pendentes sem alterar a fila diretamente.

Testes e validação

Testes manuais no navegador

Como validar manualmente:

  • abrir DevTools -> Performance;
  • rodar um cenário pesado com e sem scheduler;
  • observar long tasks, FPS e tempo entre frames;
  • usar console.time/console.timeEnd em pontos críticos para medir latência entre fatias.

Um teste simples: comparar um loop síncrono que processa 100k itens versus o mesmo usando processChunked. Você deve notar menos long tasks e frames mais regulares com o scheduler.

Testes automatizados (headless)

Contexto: aqui vai um exemplo de script mínimo com Puppeteer para medir long tasks e latências. O script injeta o scheduler e executa trabalho, coletando métricas de performance.

// Exemplo Puppeteer (node) para validar impacto
const puppeteer = require('puppeteer');
(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('about:blank');
  await page.exposeFunction('heavyWork', () => {
    // simula trabalho responsivo vs bloqueante
    const arr = new Array(100000).fill(0).map((_, i) => i * 2);
    return arr.length;
  });

  await page.evaluate(() => {
    // injeta a versão simples do scheduler no contexto da página
    // (cole o código do scheduler aqui no teste real)
  });

  // iniciar traca de performance
  const perf = await page.metrics();
  console.log('browser metrics', perf);
  await browser.close();
})();

Depois do bloco: o script acima é um esqueleto. Em testes reais você injeta a implementação completa e usa a API PerformanceObserver para capturar long tasks e eventos de frame.

Considerações de produção

Erros comuns, armadilhas e dicas práticas

Erros frequentes a evitar:

  • não medir time-slices: escolher um maxSlice arbitrário pode causar subutilização em dispositivos potentes ou travamentos em dispositivos lentos;
  • usar microtasks (Promise) para loop recursivo: microtasks podem impedir render porque executam antes do próximo render;
  • assumir availability de requestIdleCallback: fornecer fallback é obrigatório para compatibilidade;
  • não lidar com prioridade e cancelamento: tarefas de alta prioridade (input, scroll) devem poder ser processadas primeiro.
Use performance.now() para medições locais e observe que o tempo disponível em requestIdleCallback pode variar muito entre navegadores e condições de energia.

Outra armadilha é confiar que MessageChannel sempre vai dar latência baixa: em alguns ambientes ou versões antigas a diferença é marginal. Ainda é porém mais confiável que setTimeout para enfileirar sem delay artificial.

Boas práticas e deployment

Recomendações para produção:

  • expor um ponto único de configuração: ajuste maxSlice e batch por feature flag para diagnosticar em produção;
  • instrumentar com métricas: capture percentil de latência, número de long tasks e taxa de cancelamento;
  • gradualmente ativar: ligar o scheduler para uma pequena amostra de usuários antes de rollout global;
  • testar em dispositivos reais (cheap phones) e em condições de CPU limitada para encontrar parâmetros robustos.

Outra prática útil é combinar com rAF para trabalhos que precisam sincronizar com o render, por exemplo, medir layout ou atualizar posicionamento visual. Use requestIdleCallback para trabalho de baixa prioridade (analytics, indexing local) e MessageChannel para trabalho de prioridade média-alta.

Conclusão prática

Resumo

Um scheduler cooperativo baseado em MessageChannel + requestIdleCallback oferece um equilíbrio entre baixa latência e respeito ao tempo de render do navegador. Ele evita bloqueios longos, permite cancelamento e priorização simples, e é compatível com patterns modernos de Promise e closures.

Checklist para aplicar

Antes de integrar em produção, verifique:

  1. fallbacks implementados para requestIdleCallback;
  2. instrumentação para medir long tasks e frame drops;
  3. mecanismo de cancelamento para navegação ou novas buscas;
  4. testes em dispositivos reais e headless para obter métricas consistentes.

Aplicar esse pattern reduz significativamente o risco de UI travada em tarefas em lote. Em equipes, prefira a variante com tokens de cancelamento e configuração centralizada para ajustar parâmetros sem redeploy frequente.

Implementações mais complexas podem adicionar prioridades múltiplas, deadlines por tarefa e integração com um work-stealing local, mas a versão apresentada aqui é um ponto de partida prático para a maioria dos casos.

Se precisar, posso enviar uma versão TypeScript do scheduler com tipos e testes unitários integrados (Jest) para incorporar ao repositório.

Assuntos: JavaScript, scheduler, messagechannel, requestidlecallback, Performance

Leia também