← skill tree

Fundamentos JS/TS moderno

Event loop, microtasks & macrotasks: async ≠ não-bloqueante

Microtask drena inteira antes de pintar; só macrotask cede a UI. Async ≠ não-bloqueante — e a CD zoneless é macrotask (rAF/setTimeout).

revisado — correção técnica aplicada · agente cético
Modelo mental

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 isto

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):

  1. Botão "Processar (microtask)" que percorre 50.000 itens fazendo trabalho de CPU real (ex.: 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.
  2. Botão "Processar (macrotask)" com o mesmo trabalho e mesmos chunks, mas cedendo ao event loop entre chunks via um helper 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.
  3. Botão "Demo ordem" que dispara, no mesmo tick síncrono, um log em signal a partir de: código síncrono, 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.

Solução de referência
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.

Armadilhas comuns

"Tornei async, logo não trava mais" — falso

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.

Microtask starvation congela o app inteiro

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)?

DOM stale logo após signal.set (zoneless)

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.

setTimeout(0) não é 0, e não serve pra yield rápido nem pra animação

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 vs Promise.resolve().then não são intercambiáveis

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.

← voltar ao skill treefio de evolução · revisado por agente cético