Temporizadores: setTimeout e setInterval

[96] Temporizadores: setTimeout e setInterval

Um relógio que desconta um segundo a cada tique sempre atrasa: setInterval promete "não antes de", nunca "exatamente". Deste detalhe sai o resto do artigo — o cancelamento com clearTimeout, o cronômetro ancorado em Date.now, o debounce que espera a pausa e o throttle que corta pela metade.
Javascript

21 min de leitura

Até agora todo o nosso código executou imediatamente — uma linha após a outra, sem pausas. Mas aplicações reais precisam de tempo. Um toast que some após 3 segundos. Um carrossel que avança automaticamente. Um cronômetro. Um salvamento automático. Uma animação que ocorre em etapas.

Para isso existem os temporizadoressetTimeout e setInterval. São duas das funções mais usadas no JavaScript do navegador e, apesar de simples, escondem alguns comportamentos que todo desenvolvedor precisa entender.

setTimeout — executar uma vez após um delay

setTimeout agenda a execução de uma função após um tempo determinado (em milissegundos):

// Sintaxe: setTimeout(função, delay, ...argumentos)

setTimeout(() => {
  console.log("Isso executou após 2 segundos!");
}, 2000);

console.log("Esta linha executa imediatamente.");

// Saída:
// "Esta linha executa imediatamente."
// (2 segundos depois...)
// "Isso executou após 2 segundos!"

Note que o código não para enquanto espera — o JavaScript continua executando e o callback é chamado depois. Isso é a natureza assíncrona do JavaScript, que estudaremos em profundidade no Módulo 3.

Passando argumentos para o callback

function saudar(nome, periodo) {
  console.log(`Bom ${periodo}, ${nome}!`);
}

// Os argumentos extras são passados para a função
setTimeout(saudar, 1000, "Ana", "dia");
// Após 1 segundo: "Bom dia, Ana!"

Cancelando um setTimeout

setTimeout retorna um ID que você pode usar para cancelar o agendamento antes que ele execute:

const idTimer = setTimeout(() => {
  console.log("Este nunca vai executar.");
}, 5000);

// Cancela antes dos 5 segundos
clearTimeout(idTimer);
console.log("Timer cancelado!");

Isso é muito útil em validações com delay — como o salvamento automático que vimos no artigo LocalStorage e SessionStorage:

let timerSalvar = null;

input.addEventListener("input", () => {
  // Cancela o timer anterior (se existir)
  clearTimeout(timerSalvar);

  // Agenda novo salvamento após 1 segundo de inatividade
  timerSalvar = setTimeout(() => {
    salvar(input.value);
  }, 1000);
});

Esse padrão — cancelar e reagendar um timer a cada evento — se chama debounce. Voltaremos a ele em detalhes.

setInterval — executar repetidamente

setInterval executa uma função repetidamente em intervalos fixos:

// Executa a cada 1 segundo
const idIntervalo = setInterval(() => {
  console.log("Tick! " + new Date().toLocaleTimeString("pt-BR"));
}, 1000);

// Para parar o intervalo:
clearInterval(idIntervalo);

Cancelando um setInterval

Sempre guarde o ID retornado pelo setInterval — você vai precisar dele para parar:

let contador = 0;
const id = setInterval(() => {
  contador++;
  console.log(`Contagem: ${contador}`);

  if (contador >= 5) {
    clearInterval(id);
    console.log("Intervalo encerrado.");
  }
}, 500);

// Contagem: 1
// Contagem: 2
// Contagem: 3
// Contagem: 4
// Contagem: 5
// Intervalo encerrado.

O delay real não é garantido

Um detalhe importante: o delay informado é o tempo mínimo, não exato. O JavaScript é single-threaded — se a thread estiver ocupada, o callback esperará:

setTimeout(() => {
  console.log("Deveria executar em 0ms");
}, 0);

// Código pesado que bloqueia a thread por 2 segundos
const inicio = Date.now();
while (Date.now() - inicio < 2000) {}

console.log("Thread livre agora.");

// Saída:
// "Thread livre agora."
// "Deveria executar em 0ms" ← executou depois, apesar do delay 0!

setTimeout(fn, 0) não significa "agora" — significa "assim que a thread estiver livre". Isso é o Event Loop funcionando, que estudaremos no Módulo 3.

Construindo um cronômetro completo

Vamos construir um cronômetro funcional com start, pause e reset:

<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8">
  <title>Cronômetro</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }

    body {
      font-family: 'Segoe UI', sans-serif;
      background: #0f1117;
      color: #e4e6f0;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
    }

    .cronometro {
      text-align: center;
      padding: 3rem 4rem;
      background: #1a1d27;
      border-radius: 20px;
      border: 1px solid #2e3248;
      box-shadow: 0 20px 60px rgba(0,0,0,.4);
    }

    h1 {
      font-size: 1rem;
      font-weight: 500;
      color: #8b8fa8;
      letter-spacing: .15em;
      text-transform: uppercase;
      margin-bottom: 2rem;
    }

    .display {
      font-size: 5rem;
      font-weight: 700;
      font-variant-numeric: tabular-nums;
      letter-spacing: .05em;
      color: #e4e6f0;
      margin-bottom: .5rem;
    }

    .milissegundos {
      font-size: 1.5rem;
      color: #7c6ff7;
      font-variant-numeric: tabular-nums;
      margin-bottom: 2.5rem;
    }

    .botoes {
      display: flex;
      gap: 1rem;
      justify-content: center;
    }

    button {
      padding: .75rem 2rem;
      border: none;
      border-radius: 10px;
      font-size: 1rem;
      font-weight: 700;
      cursor: pointer;
      transition: all .2s;
      letter-spacing: .05em;
    }

    #btn-start {
      background: #7c6ff7;
      color: white;
      min-width: 120px;
    }

    #btn-start:hover { background: #6c5fe0; }

    #btn-lap {
      background: #232637;
      color: #e4e6f0;
      border: 1px solid #2e3248;
    }

    #btn-lap:hover { background: #2e3248; }

    #btn-reset {
      background: #232637;
      color: #f87171;
      border: 1px solid #2e3248;
    }

    #btn-reset:hover { background: #2e3248; }

    button:disabled {
      opacity: .4;
      cursor: not-allowed;
    }

    .voltas {
      margin-top: 2rem;
      max-height: 200px;
      overflow-y: auto;
      text-align: left;
    }

    .volta {
      display: flex;
      justify-content: space-between;
      padding: .5rem 0;
      border-bottom: 1px solid #2e3248;
      font-size: .9rem;
      animation: entrar .2s ease;
    }

    @keyframes entrar {
      from { opacity: 0; transform: translateX(-10px); }
      to   { opacity: 1; transform: translateX(0); }
    }

    .volta .numero { color: #8b8fa8; }
    .volta .tempo  { color: #7c6ff7; font-weight: 600; font-variant-numeric: tabular-nums; }
    .volta.melhor  .tempo { color: #4ade80; }
    .volta.pior    .tempo { color: #f87171; }
  </style>
</head>
<body>
<div class="cronometro">
  <h1>⏱ Cronômetro</h1>

  <div class="display" id="display">00:00</div>
  <div class="milissegundos" id="milissegundos">.000</div>

  <div class="botoes">
    <button id="btn-start">Iniciar</button>
    <button id="btn-lap" disabled>Volta</button>
    <button id="btn-reset" disabled>Reset</button>
  </div>

  <div class="voltas" id="voltas"></div>
</div>

<script>
  // ── Estado ──────────────────────────────────────────
  let idIntervalo = null;
  let iniciou = null;
  let totalAcumulado = 0;
  let rodando = false;
  let voltas = [];

  // ── Referências ─────────────────────────────────────
  const display = document.querySelector("#display");
  const displayMs = document.querySelector("#milissegundos");
  const btnStart = document.querySelector("#btn-start");
  const btnLap = document.querySelector("#btn-lap");
  const btnReset = document.querySelector("#btn-reset");
  const listaVoltas = document.querySelector("#voltas");

  // ── Formatação ──────────────────────────────────────
  function pad(numero, casas = 2) {
    return String(numero).padStart(casas, "0");
  }

  function formatarTempo(ms) {
    const minutos = Math.floor(ms / 60000);
    const segundos = Math.floor((ms % 60000) / 1000);
    const milissegundos = ms % 1000;
    return {
      principal: `${pad(minutos)}:${pad(segundos)}`,
      ms: `.${pad(milissegundos, 3)}`,
    };
  }

  // ── Atualização do display ──────────────────────────
  function atualizar() {
    const agora = Date.now();
    const decorrido = totalAcumulado + (agora - iniciou);
    const { principal, ms } = formatarTempo(decorrido);
    display.textContent = principal;
    displayMs.textContent = ms;
  }

  // ── Ações ────────────────────────────────────────────
  function iniciar() {
    iniciou = Date.now();
    rodando = true;

    // Atualiza a cada 10ms para mostrar milissegundos fluindo
    idIntervalo = setInterval(atualizar, 10);

    btnStart.textContent = "Pausar";
    btnLap.disabled = false;
    btnReset.disabled = false;
  }

  function pausar() {
    clearInterval(idIntervalo);
    totalAcumulado += Date.now() - iniciou;
    rodando = false;

    btnStart.textContent = "Continuar";
    btnLap.disabled = true;
  }

  function reset() {
    clearInterval(idIntervalo);
    idIntervalo = null;
    rodando = false;
    totalAcumulado = 0;
    iniciou = null;
    voltas = [];

    display.textContent = "00:00";
    displayMs.textContent = ".000";
    listaVoltas.innerHTML = "";

    btnStart.textContent = "Iniciar";
    btnLap.disabled = true;
    btnReset.disabled = true;
  }

  function registrarVolta() {
    const agora = Date.now();
    const tempoTotal = totalAcumulado + (agora - iniciou);
    const tempoVolta = voltas.length === 0
      ? tempoTotal
      : tempoTotal - voltas.reduce((acc, v) => acc + v.duracao, 0);

    voltas.push({ numero: voltas.length + 1, duracao: tempoVolta });

    renderizarVoltas();
  }

  function renderizarVoltas() {
    listaVoltas.innerHTML = "";

    if (voltas.length === 0) return;

    const duracoes = voltas.map(v => v.duracao);
    const melhor = Math.min(...duracoes);
    const pior = Math.max(...duracoes);

    // Exibe do mais recente ao mais antigo
    [...voltas].reverse().forEach(volta => {
      const div = document.createElement("div");
      div.classList.add("volta");

      if (voltas.length > 1) {
        if (volta.duracao === melhor) div.classList.add("melhor");
        if (volta.duracao === pior)   div.classList.add("pior");
      }

      const { principal, ms } = formatarTempo(volta.duracao);

      div.innerHTML = `
        <span class="numero">Volta ${volta.numero}</span>
        <span class="tempo">${principal}${ms}</span>
      `;

      listaVoltas.appendChild(div);
    });
  }

  // ── Eventos ──────────────────────────────────────────
  btnStart.addEventListener("click", () => {
    rodando ? pausar() : iniciar();
  });

  btnLap.addEventListener("click", registrarVolta);
  btnReset.addEventListener("click", reset);

  // Atalhos de teclado
  document.addEventListener("keydown", (e) => {
    if (e.code === "Space") {
      e.preventDefault();
      rodando ? pausar() : iniciar();
    }
    if (e.code === "KeyL" && rodando) registrarVolta();
    if (e.code === "KeyR" && !rodando && totalAcumulado > 0) reset();
  });
</script>
</body>
</html>

Debounce — controlar a frequência de execução

O debounce é um dos padrões mais importantes com temporizadores. Ele garante que uma função só execute após um período de inatividade — evitando execuções excessivas em eventos como input, scroll e resize:

function debounce(funcao, delay) {
  let timer;

  return function(...args) {
    // Cancela o timer anterior
    clearTimeout(timer);

    // Agenda nova execução
    timer = setTimeout(() => {
      funcao.apply(this, args);
    }, delay);
  };
}

// Uso — busca que só dispara após 500ms de inatividade
const buscar = debounce((termo) => {
  console.log(`Buscando por: "${termo}"`);
  // Aqui viria uma chamada à API
}, 500);

const input = document.querySelector("#busca");
input.addEventListener("input", (e) => {
  buscar(e.target.value);
});

Sem debounce, cada tecla dispararia uma busca. Com debounce, só a última (após a pausa) é executada.

Throttle — limitar execuções por tempo

O throttle garante que uma função execute no máximo uma vez a cada intervalo de tempo — independente de quantas vezes for chamada:

function throttle(funcao, limite) {
  let ultima = 0;

  return function(...args) {
    const agora = Date.now();

    if (agora - ultima >= limite) {
      ultima = agora;
      funcao.apply(this, args);
    }
  };
}

// Uso — scroll que executa no máximo a cada 200ms
const aoScrollar = throttle(() => {
  console.log(`Posição: ${window.scrollY}px`);
}, 200);

window.addEventListener("scroll", aoScrollar);
  Debounce Throttle
Executa Após período de inatividade A cada intervalo fixo
Uso típico Busca ao digitar, salvamento automático Scroll, resize, drag
Analogia Elevador que espera o último passageiro Semáforo com tempo fixo

setTimeout recursivo vs setInterval

Uma alternativa ao setInterval que dá mais controle:

// setInterval — intervalo fixo entre inícios de execução
// Se a função demorar 800ms e o intervalo for 1000ms,
// a próxima execução começa 200ms após a anterior terminar

// setTimeout recursivo — intervalo após o término
// Garante que a próxima execução só começa após a anterior terminar

function executarPeriodicamente() {
  console.log("Executando...");

  // Simula operação demorada
  // (com async/await isso faz mais sentido — Módulo 3)

  // Agenda próxima execução após terminar
  setTimeout(executarPeriodicamente, 1000);
}

// Inicia
setTimeout(executarPeriodicamente, 1000);

Para operações assíncronas (como chamadas à API), o setTimeout recursivo é mais seguro — garante que você não sobreponha execuções.

Boas práticas com temporizadores

// ✅ 1. Sempre guarde o ID para poder cancelar
const id = setTimeout(fn, 1000);
clearTimeout(id);

// ✅ 2. Limpe intervalos quando o componente for destruído
// Isso evita memory leaks e comportamentos fantasma
class Componente {
  constructor() {
    this.intervalo = setInterval(() => this.atualizar(), 1000);
  }

  destruir() {
    clearInterval(this.intervalo); // essencial!
  }
}

// ✅ 3. Use debounce em eventos de alta frequência
window.addEventListener("resize", debounce(() => {
  recalcularLayout();
}, 200));

// ✅ 4. Cuidado com this dentro de setInterval
// Arrow functions preservam o this do contexto externo
class Relogio {
  constructor() {
    this.horas = 0;
    // ✅ Arrow function mantém o this correto
    setInterval(() => {
      this.horas++;
      console.log(this.horas);
    }, 1000);
  }
}

// ✅ 5. setTimeout(fn, 0) para adiar sem bloquear
// Útil para deixar o DOM atualizar antes de executar algo
button.addEventListener("click", () => {
  button.textContent = "Carregando...";

  setTimeout(() => {
    // O DOM já atualizou o texto antes de executar isso
    operacaoPesada();
  }, 0);
});

Tarefa para você

Construa um timer de Pomodoro com as seguintes funcionalidades:

Regras do Pomodoro:
- 25 minutos de foco
- 5 minutos de pausa curta
- A cada 4 pomodoros, 15 minutos de pausa longa

Funcionalidades:
1. Display de contagem regressiva (MM:SS)
2. Botões: Iniciar / Pausar / Resetar
3. Indicador da fase atual (Foco / Pausa Curta / Pausa Longa)
4. Contador de pomodoros completados
5. Notificação sonora ao mudar de fase
   (use: new Audio('...').play() ou o AudioContext)
6. Ao completar um ciclo, avançar para a próxima fase automaticamente
7. Salvar o estado no LocalStorage (para persistir ao recarregar)

Dica para a contagem regressiva:

function iniciarContagem(duracaoMs) {
  const fim = Date.now() + duracaoMs;

  const intervalo = setInterval(() => {
    const restante = fim - Date.now();

    if (restante <= 0) {
      clearInterval(intervalo);
      faseConcluida();
      return;
    }

    atualizarDisplay(restante);
  }, 100); // atualiza a cada 100ms para maior precisão

  return intervalo;
}
Ver solução — o Pomodoro completo, com timer que não acumula atraso
// ---- index.html
// <div class="pomodoro">
//   <p id="fase" class="fase">Foco</p>
//   <p id="display" class="display">25:00</p>
//   <p id="ciclos">Pomodoros completados: 0</p>
//   <div class="botoes">
//     <button id="btn-iniciar">Iniciar</button>
//     <button id="btn-pausar" disabled>Pausar</button>
//     <button id="btn-resetar">Resetar</button>
//   </div>
// </div>

// ---- pomodoro.js
const FASES = {
  foco:        { rotulo: "Foco",        duracao: 25 * 60 * 1000, cor: "#e53935" },
  pausaCurta:  { rotulo: "Pausa curta", duracao:  5 * 60 * 1000, cor: "#43a047" },
  pausaLonga:  { rotulo: "Pausa longa", duracao: 15 * 60 * 1000, cor: "#1e88e5" },
};

const CHAVE = "pomodoro:v1";

const display = document.querySelector("#display");
const rotuloFase = document.querySelector("#fase");
const contadorCiclos = document.querySelector("#ciclos");
const btnIniciar = document.querySelector("#btn-iniciar");
const btnPausar = document.querySelector("#btn-pausar");
const btnResetar = document.querySelector("#btn-resetar");

let estado = {
  fase: "foco",
  restante: FASES.foco.duracao,
  pomodorosCompletos: 0,
  rodando: false,
  fimEm: null, // timestamp absoluto de quando a fase acaba
};

let intervalo = null;

// ---------------------------------------------------------------
// 1 — display MM:SS
// ---------------------------------------------------------------
function formatar(ms) {
  // Math.ceil, não floor: com floor, o último segundo aparece como
  // "00:00" enquanto ainda falta tempo, e o relógio parece travado.
  const totalSegundos = Math.ceil(ms / 1000);
  const minutos = Math.floor(totalSegundos / 60);
  const segundos = totalSegundos % 60;

  return `${String(minutos).padStart(2, "0")}:${String(segundos).padStart(2, "0")}`;
}

function renderizar() {
  const fase = FASES[estado.fase];

  display.textContent = formatar(estado.restante);
  rotuloFase.textContent = fase.rotulo;
  rotuloFase.style.color = fase.cor;
  contadorCiclos.textContent = `Pomodoros completados: ${estado.pomodorosCompletos}`;

  btnIniciar.disabled = estado.rodando;
  btnPausar.disabled = !estado.rodando;

  // Aparece na aba do navegador, que é onde a pessoa olha quando está
  // trabalhando em outra janela.
  document.title = `${formatar(estado.restante)} — ${fase.rotulo}`;
}

// ---------------------------------------------------------------
// 2 — iniciar / pausar / resetar
// ---------------------------------------------------------------
function iniciar() {
  if (estado.rodando) return;

  estado.rodando = true;
  // O fim é um INSTANTE, não uma contagem de ticks. É isto que impede
  // o relógio de atrasar — ver a nota no fim do bloco.
  estado.fimEm = Date.now() + estado.restante;

  intervalo = setInterval(tique, 100);
  renderizar();
  salvar();
}

function pausar() {
  if (!estado.rodando) return;

  clearInterval(intervalo);
  intervalo = null;
  estado.rodando = false;
  estado.restante = Math.max(0, estado.fimEm - Date.now());
  estado.fimEm = null;

  renderizar();
  salvar();
}

function resetar() {
  clearInterval(intervalo);
  intervalo = null;

  estado = {
    fase: "foco",
    restante: FASES.foco.duracao,
    pomodorosCompletos: 0,
    rodando: false,
    fimEm: null,
  };

  renderizar();
  salvar();
}

function tique() {
  estado.restante = Math.max(0, estado.fimEm - Date.now());

  if (estado.restante === 0) {
    clearInterval(intervalo);
    intervalo = null;
    concluirFase();
    return;
  }

  renderizar();
}

// ---------------------------------------------------------------
// 3, 4 e 6 — troca de fase automática
// ---------------------------------------------------------------
function proximaFase() {
  if (estado.fase !== "foco") return "foco";

  // A cada 4 pomodoros, pausa longa. O contador já foi incrementado
  // quando esta função roda, por isso o módulo é sobre ele.
  return estado.pomodorosCompletos % 4 === 0 ? "pausaLonga" : "pausaCurta";
}

function concluirFase() {
  if (estado.fase === "foco") estado.pomodorosCompletos++;

  const seguinte = proximaFase();

  estado.fase = seguinte;
  estado.restante = FASES[seguinte].duracao;
  estado.rodando = false;
  estado.fimEm = null;

  tocarAlerta();
  notificar(`${FASES[seguinte].rotulo} começando agora.`);

  renderizar();
  salvar();

  iniciar(); // encadeia sozinho, como pede o item 6
}

// ---------------------------------------------------------------
// 5 — som, sem arquivo de áudio
// ---------------------------------------------------------------
function tocarAlerta() {
  // AudioContext gera o bipe na hora: nada de depender de um .mp3 que
  // pode faltar. O contexto só pode ser criado depois de um gesto do
  // usuário — por isso é instanciado aqui, e não no carregamento.
  const contexto = new (window.AudioContext || window.webkitAudioContext)();

  const oscilador = contexto.createOscillator();
  const volume = contexto.createGain();

  oscilador.type = "sine";
  oscilador.frequency.value = 880;

  // Rampa até quase zero em vez de parada seca: corte abrupto produz
  // um clique audível.
  volume.gain.setValueAtTime(0.25, contexto.currentTime);
  volume.gain.exponentialRampToValueAtTime(0.001, contexto.currentTime + 0.6);

  oscilador.connect(volume).connect(contexto.destination);
  oscilador.start();
  oscilador.stop(contexto.currentTime + 0.6);
}

function notificar(mensagem) {
  if (!("Notification" in window)) return;

  if (Notification.permission === "granted") {
    new Notification("Pomodoro", { body: mensagem });
  } else if (Notification.permission !== "denied") {
    Notification.requestPermission();
  }
}

// ---------------------------------------------------------------
// 7 — persistência
// ---------------------------------------------------------------
function salvar() {
  localStorage.setItem(CHAVE, JSON.stringify(estado));
}

function carregar() {
  const bruto = localStorage.getItem(CHAVE);
  if (!bruto) return;

  try {
    const salvo = JSON.parse(bruto);

    // O tempo passou mesmo com a aba fechada. Se estava rodando, o
    // restante é recalculado a partir do fim absoluto — pode inclusive
    // já ter acabado.
    if (salvo.rodando && salvo.fimEm) {
      salvo.restante = Math.max(0, salvo.fimEm - Date.now());
      salvo.rodando = false;
      salvo.fimEm = null;
    }

    estado = { ...estado, ...salvo };
  } catch {
    localStorage.removeItem(CHAVE);
  }
}

btnIniciar.addEventListener("click", iniciar);
btnPausar.addEventListener("click", pausar);
btnResetar.addEventListener("click", () => {
  if (confirm("Zerar o ciclo inteiro?")) resetar();
});

carregar();
renderizar();

// ---------------------------------------------------------------
// O detalhe que morde: setInterval não conta tempo, conta convites
// ---------------------------------------------------------------
// A implementação ingênua desconta o intervalo a cada tique:
//
//   setInterval(() => { restante -= 1000; }, 1000);
//
// O navegador não garante 1000 ms. Ele garante "não antes de 1000 ms".
// Aba em segundo plano é limitada a um tique por segundo — ou menos —
// e cada atraso vira erro acumulado: 25 minutos viram 27, 30.
//
// Ancorar num instante (`fimEm`) faz o erro parar de somar: cada tique
// pergunta que horas são, em vez de contar quantas vezes já rodou. Se
// o navegador atrasar dez tiques, o próximo já mostra o valor correto.

Timer que subtrai o intervalo a cada tique sempre atrasa: setInterval promete "não antes de", nunca "exatamente", e aba em segundo plano é estrangulada de propósito. Guarde o instante do fim com Date.now() e recalcule o restante — o erro deixa de acumular e a persistência sai de graça.

setTimeout(fn, 1000) não promete executar em um segundo: promete não executar antes disso. O atraso real depende de a pilha estar livre, e é daí que nasce o erro de medir tempo somando intervalos em vez de comparar com o relógio. debounce e throttle resolvem problemas diferentes com a mesma matéria-prima — um espera a rajada terminar, o outro deixa passar um a cada tanto — e o setTimeout recursivo é a saída para quando cada execução precisa acabar antes de a próxima ser agendada.

Fontes e Referências

Exercícios

Exercício 1

Em que ordem as quatro letras aparecem no console?

console.log("A");

setTimeout(() => console.log("B"), 0);
setTimeout(() => console.log("C"), 10);

const inicio = Date.now();
while (Date.now() - inicio < 50) {} // bloqueia a thread por 50ms

console.log("D");
Ver resposta

✓ Resposta: A, D, B, C. As duas primeiras letras são fáceis de errar por hábito: A sai na hora e D sai 50 ms depois, quando o laço de bloqueio termina — mas os dois callbacks agendados não podem rodar no meio disso, porque só existe uma thread e ela está ocupada. Quando ela enfim se libera, os prazos de B (0 ms) e de C (10 ms) já venceram há muito, e os dois rodam praticamente juntos, na ordem em que venceram: primeiro B, depois C. Duas conclusões que valem para todo temporizador: o delay é um mínimo, um pedido de "não antes disto", e o atraso real depende inteiramente do que a thread estiver fazendo. Por isso setTimeout(fn, 0) não quer dizer "agora", e sim "na primeira folga" — o que, aliás, é exatamente o uso legítimo dele: deixar o navegador pintar a tela antes de começar um trabalho pesado.

Exercício 2

Esta contagem regressiva de 60 segundos foi deixada rodando numa aba em segundo plano. Ao voltar, o display marca menos de um minuto decorrido, mais, ou exatamente um minuto?

let restante = 60;

const id = setInterval(() => {
  restante--;
  atualizarDisplay(restante);
  if (restante === 0) clearInterval(id);
}, 1000);
Ver resposta

✓ Resposta: Sempre mais de um minuto — nunca menos, nunca exato. Este relógio não mede tempo: conta quantas vezes foi chamado, e supõe que cada chamada valeu exatamente mil milissegundos. Como o navegador só promete "não antes de", cada tique chega com um pequeno atraso, e os atrasos somam, porque nada nesse código volta a olhar para o relógio de verdade. Em primeiro plano o erro é de alguns segundos ao longo de vinte e cinco minutos; em segundo plano ele explode, porque o navegador estrangula os temporizadores de abas ocultas de propósito, para poupar bateria — o intervalo cai para cerca de um por segundo e, depois de alguns minutos escondido, para muito menos que isso. Um Pomodoro de 25 minutos pode terminar aos 30. A correção não é aumentar a precisão do intervalo, e sim mudar o que se guarda: registre o instante do fim com const fimEm = Date.now() + duracao e faça cada tique calcular fimEm - Date.now(). O tique passa a ser só um pedido de repintura, e um atraso deixa de virar erro permanente.

Exercício 3

O usuário digita dez caracteres, um a cada 100 ms, e para. Quantas vezes buscar é chamada em cada versão — e qual delas não busca o texto final?

const comDebounce = debounce(buscar, 500);
const comThrottle = throttle(buscar, 500);

// as duas implementações são as do artigo
Ver resposta

✓ Resposta: Com debounce, uma chamada; com throttle, duas. E é o throttle que não busca o texto final. O debounce cancela e reagenda a cada tecla, então nada acontece durante a digitação e a única execução ocorre 500 ms depois da última tecla — carregando o valor completo. O throttle do artigo dispara na borda de entrada: executa na primeira tecla, quando o campo tem um caractere, ignora tudo até completar 500 ms, executa de novo na tecla dos 500 ms, com seis caracteres, e depois descarta as quatro últimas — inclusive a décima, que é a que o usuário realmente quis buscar. Essa implementação não tem chamada de saída, e por isso o throttle é a ferramenta errada para caixa de busca e certa para scroll, resize e arrastar, onde o que interessa é reagir com regularidade e o último valor chega de qualquer jeito no evento seguinte. Bibliotecas sérias oferecem as opções leading e trailing justamente para permitir as duas bordas.

Exercício 4

Os dois métodos parecem equivalentes. O que acontece ao chamar cada um?

class Relogio {
  constructor() { this.horas = 0; }

  iniciarA() {
    setInterval(function () {
      this.horas++;
      console.log(this.horas);
    }, 1000);
  }

  iniciarB() {
    setInterval(() => {
      this.horas++;
      console.log(this.horas);
    }, 1000);
  }
}
Ver resposta

✓ Resposta: O iniciarB imprime 1, 2, 3… como se espera. O iniciarA lança TypeError: Cannot read properties of undefined — uma vez por segundo, para sempre, porque exceção dentro de callback de temporizador não cancela o intervalo. A causa é que o this de uma function comum é definido por como ela é chamada, e quem a chama é o temporizador, não a instância; corpo de classe roda sempre em modo estrito, e nele o this de uma chamada avulsa é undefined. A arrow não tem this próprio: ela toma emprestado o do lugar onde foi escrita, que aqui é o método, onde this é a instância. Vale conhecer a variante mais perigosa: o mesmo código fora de uma classe, num script comum sem "use strict", não dá erro nenhum — this vira o objeto global, globalThis.horas é undefined, o incremento produz NaN e o console passa a imprimir NaN a cada segundo, sem uma linha de aviso.

Exercício 5

O modal é fechado com remove(). O intervalo para junto?

function abrirModal() {
  const modal = document.querySelector("#modal");
  const relogio = modal.querySelector(".relogio");

  setInterval(() => {
    relogio.textContent = new Date().toLocaleTimeString("pt-BR");
  }, 1000);

  modal.querySelector(".fechar")
       .addEventListener("click", () => modal.remove());
}
Ver resposta

✓ Resposta: Não para. O remove() tira o elemento da árvore do documento, mas o temporizador foi registrado na janela e continua na agenda dela — o navegador não tem como saber que aquele callback só interessava enquanto o modal existia. A cada segundo ele escreve num elemento que ninguém vê, sem erro e sem efeito visível. Pior: o callback mantém uma referência ao elemento, então o modal removido não pode nem ser coletado da memória; ele fica preso ali, vivo, pelo próprio timer que deveria ter morrido com ele. E como o setInterval foi criado dentro da função de abrir, cada reabertura acrescenta mais um: com o modal aberto cinco vezes, são cinco intervalos escrevendo, quatro deles em elementos fantasmas. O sintoma que aparece primeiro raramente é a memória — é a ação que passa a acontecer em dobro, em triplo, quando o callback faz algo visível, como enviar uma requisição. A regra é curta: quem cria o temporizador é responsável por cancelá-lo, guardando o id e chamando clearInterval no mesmo lugar em que o elemento é destruído.

Comentários

Mais em Javascript

Funções: declaração, expressão e arrow functions
Funções: declaração, expressão e arrow functions

Uma função sem return devolve undefined — e uma arrow com chaves, mas sem…

Async/Await: escrevendo código assíncrono de forma limpa
Async/Await: escrevendo código assíncrono de forma limpa

A cadeia de .then resolveu a pirâmide mas espalhou os valores por escopos…

Revisão + Projeto Final: Produção Real
Revisão + Projeto Final: Produção Real

Deploy, segurança e performance só existem depois que a aplicação existe, e…