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 é:
- usar MessageChannel para agendar a próxima fatia de execução com baixa latência;
- usar requestIdleCallback quando disponível para aproveitar os tempos ociosos;
- fornecer controle de duração por fatia (time-slicing) e prioridade;
- 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:
- fallbacks implementados para requestIdleCallback;
- instrumentação para medir long tasks e frame drops;
- mecanismo de cancelamento para navegação ou novas buscas;
- 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
Criando um simulador interativo do Event Loop: Call Stack, Tasks e Microtasks
Aprenda, do zero, como call stack, microtasks e tasks interagem no event loop. Construa um simulador interativo que demonstra a ordem de execução, evite…
Implementando Token Bucket no Service Worker para controlar fetchs entre abas
Token Bucket no Service Worker: controle de fetchs entre abas com estado centralizado, refill baseado em timestamps, enfileiramento seguro e persistência em…
Web Components semânticos para formulários: labels, validação e acessibilidade
Padrões práticos para construir Web Components de formulário mantendo labels, validação e acessibilidade: quando usar Shadow DOM, ElementInternals e fallbacks…
Retry resiliente com fetch: AbortController, backoff exponencial e streaming
Padrões práticos para retries resilientes com fetch: combine AbortController para cancelamento, backoff exponencial com jitter para evitar picos, e streaming…
WebAssembly com Rust para o navegador: compilar e usar com JS (iniciantes)
Guia prático para iniciantes: compile Rust para WebAssembly e consuma no navegador via JS. Passo a passo para tokenizacao, cálculo de scores e integração com…
Pool de concorrência com Async Iterators e AbortSignal
Implementar um pool de concorrência com Async Iterators e AbortSignal para controlar tarefas assíncronas, permitir cancelamento cooperativo e aplicar…