A regra que causa quase tudo
O JavaScript trata duas strings quase iguais de formas opostas, e está na especificação:
| String | Interpretada como | No Brasil vira |
|---|---|---|
'2026-08-16' |
UTC — meia-noite em Greenwich | 15/08 às 21h |
'2026-08-16T00:00:00' |
horário local | 16/08 às 00h |
'2026-08-16T00:00:00Z' |
UTC, explícito | 15/08 às 21h |
'2026-08-16T00:00:00-03:00' |
o fuso que você escreveu | 16/08 às 00h |
Repare: só de tirar o horário da string, o significado muda
de local para UTC. É a origem do “sumiu um dia” — a data que veio do banco
como 2026-08-16 vira 15 de agosto às nove da noite, e
getDate() devolve 15.
O segundo culpado: toISOString()
Este fecha o ciclo do bug. O toISOString()
sempre converte para UTC, então o padrão mais comum de
salvar data no Brasil está errado:
// usuário escolheu 16/08 no seletor, às 20h de Brasília
const d = new Date(2026, 7, 16) // 16/08 00:00 local
d.toISOString() // '2026-08-16T03:00:00.000Z'
d.toISOString().slice(0, 10) // '2026-08-16' ← funcionou
// mas se a hora local for antes das 21h... na verdade:
const e = new Date('2026-08-16') // interpretada como UTC!
e.toISOString().slice(0, 10) // '2026-08-16'
e.getDate() // 15 ← aqui aparece
A regra prática: toISOString().slice(0,10) devolve
o dia em Greenwich, não o dia de quem escolheu. Para pegar
a data local:
// pt-BR devolve dd/mm/aaaa; en-CA devolve aaaa-mm-dd, que é o que você quer
const dataLocal = d.toLocaleDateString('en-CA') // '2026-08-16'
// ou explícito, com o fuso que interessa
new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Sao_Paulo' }).format(d)
A pegadinha do getTimezoneOffset()
O sinal é invertido em relação ao que todo mundo espera. No
Brasil, que é UTC−3, ele devolve 180 — positivo:
new Date().getTimezoneOffset() // 180 em Brasília, não -180
O valor responde “quantos minutos preciso somar ao horário local para chegar no UTC”. Faz sentido para a conta, e trai quem lê rápido.
A distinção que resolve o problema de vez
Quase todo bug de fuso vem de misturar duas coisas diferentes:
Instante é um ponto na linha do tempo, igual para todo mundo: quando o pedido foi criado, quando o login aconteceu. Guarde em UTC, converta só na hora de mostrar.
Data de calendário não é um instante: data de nascimento, vencimento de boleto, feriado. O aniversário de quem nasceu em 16 de agosto é 16 de agosto no mundo inteiro — não existe “meia-noite de qual fuso”.
| Instante | Data de calendário | |
|---|---|---|
| Exemplos | criado_em, login | nascimento, vencimento |
| Coluna no MySQL | DATETIME em UTC | DATE |
| No JavaScript | Date | string '2026-08-16', sem virar Date |
| Erro típico | guardar no fuso local | converter para UTC |
Guardar data de nascimento como timestamp é o caminho garantido para o aniversário andar um dia dependendo de onde a pessoa abre o sistema.
O Brasil não tem mais horário de verão — mas o histórico tem
O horário de verão foi extinto em 2019. Hoje o país é −03:00 fixo (com −04:00 e −05:00 em parte do Norte e Oeste).
Só que datas anteriores a 2019 continuam sujeitas ao antigo horário de verão. Se o seu sistema tem histórico, uma data de dezembro de 2018 estava em −02:00, e é por isso que se calcula fuso pelo nome da região, nunca por um número fixo:
// certo — a base de fusos sabe o histórico
new Intl.DateTimeFormat('pt-BR', { timeZone: 'America/Sao_Paulo' })
// errado — quebra em qualquer data anterior a 2019
const dataLocal = new Date(utc.getTime() - 3 * 60 * 60 * 1000)
Como testar antes de o usuário reclamar
Bug de fuso quase nunca aparece para quem programa, porque a máquina de desenvolvimento costuma estar no mesmo fuso do teste. Force outro:
# roda a suíte como se você estivesse em outro lugar
TZ=Pacific/Kiritimati npm test # UTC+14, o mais adiantado do mundo
TZ=Pacific/Midway npm test # UTC-11, o mais atrasado
TZ=UTC npm test
Se a suíte passa nos três, o código não depende do fuso da máquina — que é a única garantia que vale.
Outras ferramentas de diagnóstico: faixas do semver, acentuação quebrada, erro de npm.