Fundamentos JS/TS moderno
Microtask drena inteira antes de pintar; só macrotask cede a UI. Async ≠ não-bloqueante — e a CD zoneless é macrotask (rAF/setTimeout).
Por que importa (nível arquitetura, não trivia de entrevista). A main thread do browser é um único while-loop: desempilha a call stack até esvaziar, drena a fila de microtasks INTEIRA, dá uma chance de render, e só então pega UMA macrotask. Repete. A consequência que quase todo dev sênior recita mas erra na prática: "tornar assíncrono" e "não bloquear a UI" são coisas diferentes. Promise, await e queueMicrotask são async de microtask — cedem a call stack mas nunca cedem ao paint nem a input. Se você quebra um trabalho pesado em 50 pedaços e reagenda cada pedaço com queueMicrotask/Promise.then, o browser continua congelado: todos os 50 pedaços rodam dentro do mesmo ciclo de drenagem de microtask, antes de qualquer pixel. Isso é starvation: uma microtask que reagenda outra microtask segura o event loop refém — timers não disparam, requestAnimationFrame não roda, a aba trava. O único jeito de devolver o controle ao loop (pintar, processar clique, rodar setTimeout) é agendar uma macrotask ou usar o scheduler (scheduler.postTask, MessageChannel, requestIdleCallback).
O modelo em uma frase: microtask = "termina isto antes de respirar"; macrotask = "respira, depois continua". Você escolhe microtask quando quer coalescer/batch (juntar N mudanças síncronas numa só reação, garantidamente antes do próximo frame) e macrotask/scheduler quando quer fatiar um trabalho longo mantendo a UI viva. A ordem entre elas é determinística: síncrono → toda a fila de microtask (FIFO) → render → uma macrotask. Por isso setTimeout(fn,0) sempre roda depois de um Promise.then enfileirado no mesmo tick — e o setTimeout(0) nem é 0: tem clamp de ~4ms após aninhamento, então não serve pra yield de baixa latência (use MessageChannel ou postTask).
O gancho Angular 21 (zoneless). Sem Zone.js, o Angular não faz monkey-patch de setTimeout/Promise. Quando um signal muda, ele notifica o scheduler, que agenda change detection como um MACROTASK — internamente via scheduleCallbackWithRafRace: um requestAnimationFrame correndo contra um setTimeout(0) (o rAF ganha quando a aba está visível; o setTimeout é o fallback pra abas ocultas, onde rAF não dispara). Não é microtask no caminho padrão. Existe um caminho de microtask (switchToMicrotaskScheduler, inscrito em afterTick), mas ele só liga temporariamente pra coalescer notificações reentrantes que ocorrem durante um tick já em andamento — não pra uma mudança de signal vinda de um event handler fresco. Isso tem uma implicação arquitetural contra-intuitiva: logo após this.count.set(3), o DOM ainda está stale — a CD está agendada (como macrotask), não executada. Ler element.textContent na linha seguinte devolve o valor antigo; o DOM só reflete depois que a stack síncrona desenrola e o macrotask de CD (rAF/setTimeout) roda. Para agir após o render você usa afterNextRender/afterRenderEffect, não um setTimeout chutado. E o oposto: um trabalho que satura a fila de microtask também starva a CD do Angular — como a CD é agendada como macrotask, e o loop só chega a qualquer macrotask depois de esvaziar todas as microtasks, seu app inteiro para de renderizar. Entender o event loop aqui não é academia; é a diferença entre um progress bar que anima e um que pula de 0 a 100 depois de congelar a aba.
Constrói: um "Event Loop Lab" — um componente standalone Angular 21 que PROVA na tela a diferença entre microtask e macrotask.
Requisitos (critérios de pronto):
Math.sqrt/Math.sin acumulados), fatiado em chunks de 1.000, reagendando cada chunk com queueMicrotask, atualizando um signal progress a cada chunk. Resultado esperado: a barra de progresso NÃO anima — pula de 0 a 100% e a aba trava durante o cálculo. Esse é o ponto: parece assíncrono, mas starva o paint.yieldToMacrotask() construído com MessageChannel (não setTimeout, pra evitar o clamp de 4ms). Resultado esperado: a barra anima suavemente e a UI continua clicável.setTimeout(…,0), Promise.resolve().then e queueMicrotask — e renderiza a ordem REAL de execução numa lista. Deve provar: síncrono → microtasks (FIFO) → macrotask.Sem console.log como entrega — tudo aparece na tela via signals + @for/@if. Rode ng serve, clique nos três botões, e confirme com os próprios olhos que microtask ≠ responsividade.
Bônus (15 min): troque yieldToMacrotask por scheduler.postTask({priority:'user-visible'}) e note que roda mais cedo que setTimeout mas ainda cede ao paint.
import {
Component,
ChangeDetectionStrategy,
signal,
computed,
} from '@angular/core';
/**
* Cede ao PRÓXIMO macrotask via MessageChannel.
* Por que não setTimeout(0)? O browser aplica um clamp mínimo (~4ms após
* aninhamento) — péssimo pra yield de baixa latência. postMessage não tem
* esse clamp e ainda cede ao event loop (deixa o paint e input acontecerem).
*/
function yieldToMacrotask(): Promise<void> {
return new Promise<void>((resolve) => {
const ch = new MessageChannel();
ch.port1.onmessage = () => {
ch.port1.close();
resolve();
};
ch.port2.postMessage(null);
});
}
// Trabalho de CPU real por item — o suficiente pra 50k travar visivelmente.
function heavy(i: number): number {
let x = i;
for (let k = 0; k < 40; k++) x = Math.sqrt(x * x + Math.sin(x) + k);
return x;
}
type Mode = 'idle' | 'microtask' | 'macrotask';
@Component({
selector: 'app-event-loop-lab',
changeDetection: ChangeDetectionStrategy.OnPush,
// standalone é o default no Angular 21 — sem NgModule, sem `standalone: true`.
template: `
<section class="lab">
<h2>Event Loop Lab</h2>
<div class="bar" [class.busy]="running()">
<div class="fill" [style.width.%]="percent()"></div>
<span class="pct">{{ percent() }}%</span>
</div>
<div class="row">
<button (click)="processMicrotask()" [disabled]="running()">
Processar (microtask) — trava a UI
</button>
<button (click)="processMacrotask()" [disabled]="running()">
Processar (macrotask) — anima
</button>
<button (click)="demoOrder()" [disabled]="running()">
Demo ordem
</button>
</div>
@if (mode() !== 'idle') {
<p class="meta">
modo: <strong>{{ mode() }}</strong>
@if (!running() && result() !== null) {
· checksum: {{ result()!.toFixed(2) }}
}
</p>
}
@if (order().length) {
<ol class="order">
@for (line of order(); track $index) {
<li>{{ line }}</li>
}
</ol>
}
<!-- Prova de vida: um contador que só avança se o event loop respira.
No modo microtask ele congela junto com a barra. -->
<p class="alive">tick vivo: {{ heartbeat() }}</p>
</section>
`,
styles: [
`
.lab { font: 14px/1.5 system-ui; max-width: 560px; }
.bar {
position: relative; height: 28px; border-radius: 6px;
background: color-mix(in srgb, currentColor 10%, transparent);
overflow: hidden; margin: 12px 0;
}
.fill {
height: 100%; background: #3b82f6; transition: width 60ms linear;
}
.pct {
position: absolute; inset: 0; display: grid; place-items: center;
font-variant-numeric: tabular-nums;
}
.row { display: flex; gap: 8px; flex-wrap: wrap; }
button { padding: 8px 12px; cursor: pointer; }
button:disabled { opacity: .5; cursor: not-allowed; }
.order { margin: 12px 0; padding-left: 20px; }
.meta, .alive { color: color-mix(in srgb, currentColor 65%, transparent); }
`,
],
})
export class EventLoopLabComponent {
private readonly total = 50_000;
readonly progress = signal(0);
readonly running = signal(false);
readonly mode = signal<Mode>('idle');
readonly result = signal<number | null>(null);
readonly order = signal<string[]>([]);
readonly heartbeat = signal(0);
readonly percent = computed(() =>
Math.round((this.progress() / this.total) * 100),
);
constructor() {
// Bate a cada macrotask. No modo microtask a fila de microtask nunca cede,
// então este setTimeout (macrotask) congela — evidência visível de starvation.
const beat = () => {
this.heartbeat.update((n) => n + 1);
setTimeout(beat, 250);
};
beat();
}
/**
* ARMADILHA proposital: fatiar em chunks NÃO basta se você reagenda com
* microtask. Todos os 50 chunks drenam no MESMO ciclo de microtask, antes
* de qualquer paint. A barra pula 0 -> 100 e a aba trava.
*/
processMicrotask(): void {
this.reset('microtask');
this.running.set(true);
let i = 0;
let acc = 0;
const step = () => {
const end = Math.min(i + 1000, this.total);
for (; i < end; i++) acc += heavy(i);
// signal muda e marca a view suja, mas a CD zoneless é agendada como
// MACROTASK (rAF/setTimeout) — e nenhum macrotask roda enquanto a fila
// de microtask não esvaziar. Logo, nada pinta.
this.progress.set(i);
if (i < this.total) {
queueMicrotask(step); // <- reagenda como MICROTASK: starva o paint
} else {
this.result.set(acc);
this.running.set(false);
}
};
step();
}
/**
* Correto: mesmo trabalho, mesmos chunks, mas cede ao event loop entre
* chunks. O await de um macrotask deixa o macrotask de CD do Angular (rAF-race)
* rodar, o browser pintar, e input ser processado. A barra anima.
*/
async processMacrotask(): Promise<void> {
this.reset('macrotask');
this.running.set(true);
let acc = 0;
for (let i = 0; i < this.total; ) {
const end = Math.min(i + 1000, this.total);
for (; i < end; i++) acc += heavy(i);
this.progress.set(i);
await yieldToMacrotask(); // <- cede: paint + CD + input entre chunks
}
this.result.set(acc);
this.running.set(false);
}
/**
* Prova a ordem determinística: síncrono -> microtasks (FIFO) -> macrotask.
*/
demoOrder(): void {
this.mode.set('idle');
this.order.set([]);
const log = (s: string) => this.order.update((a) => [...a, s]);
log('1 · síncrono (início)');
setTimeout(() => log('5 · setTimeout 0 (macrotask)'), 0);
// Enfileirado no .then (promise já resolvida) — entra antes do queueMicrotask abaixo.
Promise.resolve().then(() => log('3 · Promise.then (microtask)'));
queueMicrotask(() => log('4 · queueMicrotask (microtask)'));
log('2 · síncrono (fim)');
// Execução real: 1, 2, 3, 4, 5.
}
private reset(mode: Mode): void {
this.mode.set(mode);
this.progress.set(0);
this.result.set(null);
this.order.set([]);
}
}Decisão-chave 1 — o contraste é o produto. Os dois processadores são idênticos exceto por UMA linha: queueMicrotask(step) vs await yieldToMacrotask(). Isso isola cirurgicamente a variável. O modo microtask fatia o trabalho igualzinho, mas como microtask reagenda microtask, tudo drena num único ciclo antes do paint — barra pula, e o heartbeat (que roda em setTimeout) congela junto, provando que macrotasks também morrem sob starvation de microtask. O modo macrotask cede o loop entre chunks, então a CD zoneless do Angular (agendada como macrotask via rAF-race) roda, o browser pinta, e o heartbeat continua batendo. É a lição inteira em duas linhas divergentes.
Decisão-chave 2 — MessageChannel, não setTimeout(0). Pro yield eu uso MessageChannel.postMessage, que é macrotask mas sem o clamp mínimo de ~4ms que o setTimeout sofre após aninhamento. Com 50 chunks, setTimeout adicionaria ~200ms de latência artificial só de clamp; postMessage cede na primeira oportunidade real. Em produção, scheduler.postTask({priority}) é o sucessor idiomático — dá prioridades e cooperação com o scheduler do browser — mas MessageChannel é zero-dependência e ilustra o mecanismo. O heartbeat deliberadamente usa setTimeout recursivo como "canário": só avança se o event loop estiver respirando.
Decisão-chave 3 — zoneless-friendly de propósito. OnPush + signals, sem Zone.js no caminho. Note que this.progress.set(i) marca a view suja mas não pinta na hora — a CD está agendada como macrotask (o scheduleCallbackWithRafRace: um requestAnimationFrame correndo contra um setTimeout(0) de fallback), não como microtask. No modo macrotask isso funciona a nosso favor: cada await yieldToMacrotask() devolve o loop, e aí o macrotask de CD ganha vez, roda e pinta antes do próximo chunk. No modo microtask, esse mesmo macrotask de CD nunca é alcançado — a fila de microtask não esvazia, e o loop nem chega à fase de render. O demoOrder numera cada log pela ordem real de execução (1,2 síncronos → 3,4 microtasks em FIFO → 5 macrotask), e como os logs vivem num signal renderizado por @for, a prova aparece na tela, não no console.
Trocar um loop síncrono por Promise/await/queueMicrotask recursivo NÃO desbloqueia a UI. Microtasks drenam por completo antes de qualquer paint ou input. Só ceder a uma macrotask (MessageChannel, scheduler.postTask, setTimeout) ou a um Worker devolve responsividade. "Assíncrono" e "não-bloqueante" são eixos independentes.
Uma microtask que enfileira outra microtask (Promise.then encadeado recursivo, queueMicrotask em loop) segura o event loop indefinidamente: setTimeout não dispara, requestAnimationFrame não roda, e — em zoneless — a change detection do Angular também nunca executa. Atenção ao mecanismo: a CD zoneless é agendada como MACROTASK (via scheduleCallbackWithRafRace — rAF racing setTimeout(0)), e como o loop só toca qualquer macrotask depois de esvaziar TODA a fila de microtask, saturar microtasks trava todos os macrotasks — a CD junto. Sua app fica viva no processo mas morta na tela. Sempre que reagendar trabalho, pergunte: microtask (batch) ou macrotask (yield)?
Depois de this.count.set(x), o DOM ainda tem o valor antigo na próxima linha síncrona — a CD está agendada (como macrotask rAF/setTimeout), não executada. Ler element.textContent/offsetHeight ali devolve stale, e forçar leitura de layout pode até causar reflow com dado velho. Para agir pós-render use afterNextRender/afterRenderEffect, nunca um setTimeout chutado.
O browser aplica clamp mínimo (~4ms após ~5 níveis de aninhamento; ~1ms antes). Pra ceder ao loop com baixa latência use MessageChannel ou scheduler.postTask; pra animação use requestAnimationFrame (sincronizado com o refresh e pausado em abas ocultas), nunca setTimeout, que dessincroniza do frame e desperdiça bateria.
queueMicrotask não aloca uma Promise e — crucial — não engole exceções: um throw dentro dele vira erro não-tratado reportado ao window.onerror, enquanto num .then vira uma rejeição silenciosa que some se ninguém encadeou .catch. Use queueMicrotask quando quer só adiar até o fim do tick sem semântica de promise; use Promise quando precisa compor/aguardar o resultado.