Voltar para Artigos
Back-end12 min de leitura

O Pesadelo dos Timezones: Manipulando Datas com date-fns

UTC como padrão universal, ISO 8601 no transporte, date-fns vs Temporal API, date-fns-tz para conversões de timezone, armadilhas do new Date() e como estruturar lógica de negócio com datas.

12 de agosto de 2026

Datas e fusos horários são responsáveis por uma categoria inteira de bugs difíceis de reproduzir: o agendamento que aparece uma hora errada para o cliente de Manaus, o relatório de vendas que muda de total dependendo do servidor, o cron que dispara no horário errado no horário de verão. Esses bugs não aparecem em desenvolvimento local e chegam em produção às 2h da manhã.

A raiz do problema é a confusão entre instante (um ponto específico no tempo, independente de fuso) e representação local (como esse instante é exibido num fuso específico). Este artigo estabelece os princípios para trabalhar corretamente com datas em aplicações Node.js e React, usando date-fns e date-fns-tz.

O Princípio Fundamental: UTC no Servidor, Local no Cliente

  1. Frontend — captura a data/hora no fuso do usuário, converte para UTC e envia no formato ISO 8601 com sufixo Z (ex: 2024-12-01T17:00:00.000Z)
  2. Backend — armazena em UTC no banco. Faz cálculos, comparações e lógica de negócio sempre em UTC. Nunca converte para timezone regional.
  3. Frontend — recebe as datas do backend em UTC e converte para o timezone local do usuário apenas na camada de exibição.

O banco de dados deve usar o tipo TIMESTAMP WITH TIME ZONE (PostgreSQL) ou DATETIME armazenado como UTC (MySQL com explicit_defaults_for_timestamp=ON). Configure o TypeORM para usar UTC:

dataSource.ts
import { DataSource } from 'typeorm';

export const dataSource = new DataSource({
  type: 'postgres',
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT ?? 5432),
  username: process.env.DB_USER,
  password: process.env.DB_PASS,
  database: process.env.DB_NAME,

  // Garante que o TypeORM e o PostgreSQL conversam em UTC
  // Sem isso, o PostgreSQL pode aplicar o timezone do servidor
  extra: {
    options: '-c timezone=UTC',
  },
});

// Variável de ambiente do sistema também deve ser UTC:
// process.env.TZ = 'UTC'; // No topo do entry point, antes de qualquer import de data
// Ou no script de start: TZ=UTC node dist/server.js

Armadilhas do new Date() no Node.js

O objeto Date do JavaScript é famoso por suas pegadinhas. Aqui estão as mais comuns:

armadilhas-datas.ts
// ❌ ARMADILHA 1: Parsing de string sem timezone
// Comportamento diferente em browsers vs Node.js!
new Date('2024-12-01')         // Interpreted as UTC midnight: 2024-12-01T00:00:00Z
new Date('2024-12-01T14:00:00') // Interpreted as LOCAL time: depende do TZ do sistema!

// ✅ CORRETO: sempre use ISO 8601 com timezone explícito
new Date('2024-12-01T14:00:00Z')         // UTC explícito
new Date('2024-12-01T14:00:00-03:00')    // Brasília explícito (converte para UTC internamente)

// ❌ ARMADILHA 2: toLocaleDateString() usa o timezone do SERVIDOR
// Se o servidor está em UTC, não mostrará o horário de Brasília
date.toLocaleDateString('pt-BR') // Resultado depende do TZ do servidor!

// ❌ ARMADILHA 3: Comparação de datas
// datas são objetos — '===' compara referências, não valores
const d1 = new Date('2024-01-01');
const d2 = new Date('2024-01-01');
d1 === d2       // false! Referências diferentes
d1.getTime() === d2.getTime() // true ✅
// Use date-fns: isEqual(d1, d2) ✅

date-fns: Imutável e Tree-shakeable

O date-fns é a alternativa moderna ao Moment.js: imutável (cada função retorna uma nova data), tree-shakeable (você importa apenas o que usa), e puramente funcional.

bash
npm install date-fns date-fns-tz
CreateAppointmentUseCase.ts
import {
  startOfHour,
  isBefore,
  isAfter,
  isEqual,
  addHours,
  differenceInMinutes,
  format,
  parseISO,
} from 'date-fns';
import { ptBR } from 'date-fns/locale';

export class CreateAppointmentUseCase {
  async execute({ dateISO, providerId, customerId }: CreateAppointmentDTO) {
    // parseISO: garante parsing correto de string ISO 8601
    // NUNCA use new Date(dateString) com strings sem timezone
    const requestedDate = parseISO(dateISO);

    // Agendamentos só ocorrem em horas cheias
    // startOfHour: trunca os minutos e segundos (14:37 → 14:00)
    const appointmentDate = startOfHour(requestedDate);

    // Impede agendamento no passado
    if (isBefore(appointmentDate, new Date())) {
      throw new AppError('Não é possível agendar em datas passadas.');
    }

    // Horário comercial: 8h às 17h (última hora é 17:00 para encerrar às 18h)
    const openingHour = 8;
    const closingHour = 17;
    const hour = appointmentDate.getUTCHours(); // UTC hour

    if (hour < openingHour || hour > closingHour) {
      throw new AppError('Agendamentos apenas entre 08:00 e 17:00.');
    }

    // Verifica conflito de horário
    const existingAppointment = await this.appointmentsRepo.findByDateAndProvider(
      appointmentDate,
      providerId
    );

    if (existingAppointment) {
      throw new AppError('Este horário já está reservado.');
    }

    const appointment = await this.appointmentsRepo.create({
      id: crypto.randomUUID(),
      date: appointmentDate,
      providerId,
      customerId,
    });

    // Para logs/notificações: formata em PT-BR
    const formattedDate = format(
      appointmentDate,
      "EEEE, d 'de' MMMM 'às' HH:mm",
      { locale: ptBR }
    );
    // Resultado: 'segunda-feira, 1 de dezembro às 14:00'

    return { appointment, formattedDate };
  }
}

date-fns-tz: Conversões de Timezone

Para sistemas multi-timezone (usuários em fusos diferentes), use date-fns-tz para converter entre timezones de forma explícita:

timezone.ts
import { toZonedTime, fromZonedTime, format } from 'date-fns-tz';

// Cenário: exibir horário de um evento para usuários em fusos diferentes
function displayEventTime(utcDate: Date, userTimezone: string): string {
  // Converte o instante UTC para a representação local do timezone do usuário
  const zonedDate = toZonedTime(utcDate, userTimezone);

  // Formata exibindo o offset do timezone
  return format(zonedDate, "dd/MM/yyyy HH:mm (zzz)", { timeZone: userTimezone });
}

// Evento armazenado: 2024-12-01T17:00:00Z (UTC)
const eventUTC = new Date('2024-12-01T17:00:00Z');

console.log(displayEventTime(eventUTC, 'America/Sao_Paulo'));
// → '01/12/2024 14:00 (GMT-3)'  (horário de Brasília)

console.log(displayEventTime(eventUTC, 'America/Manaus'));
// → '01/12/2024 13:00 (GMT-4)'  (horário de Manaus)

console.log(displayEventTime(eventUTC, 'America/New_York'));
// → '01/12/2024 12:00 (GMT-5)'  (horário de Nova York)

// Cenário inverso: usuário informou horário LOCAL, converte para UTC
function userLocalToUTC(localDateString: string, userTimezone: string): Date {
  // Interpreta a string como horário local do timezone do usuário
  // e converte para UTC
  return fromZonedTime(localDateString, userTimezone);
}

// Usuário em Manaus diz: 'quero agendar para 14:00 de 01/12'
const utcForStorage = userLocalToUTC('2024-12-01 14:00:00', 'America/Manaus');
console.log(utcForStorage.toISOString());
// → '2024-12-01T18:00:00.000Z'  (14:00 em Manaus = 18:00 UTC)

Formatação no Frontend React

O frontend recebe datas sempre em UTC (ISO 8601 com Z) e deve converter para o timezone local do usuário para exibição:

formatDate.ts (Frontend)
import { format, formatRelative, formatDistanceToNow } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { toZonedTime } from 'date-fns-tz';

// Timezone do usuário detectado automaticamente pelo browser
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// → 'America/Sao_Paulo' (ou o timezone do usuário)

export function formatLocalDate(utcDateString: string): string {
  const utcDate = new Date(utcDateString); // ISO 8601 com Z → parse correto
  const localDate = toZonedTime(utcDate, userTimezone);
  return format(localDate, "dd/MM/yyyy 'às' HH:mm", { locale: ptBR });
}

export function formatRelativeDate(utcDateString: string): string {
  const utcDate = new Date(utcDateString);
  // 'há 2 dias', 'em 3 horas', 'há 5 minutos'
  return formatDistanceToNow(utcDate, { addSuffix: true, locale: ptBR });
}

// Para inputs de data e hora que o usuário preenche:
// 1. Capture com <input type="datetime-local"> (retorna horário local)
// 2. Converta com fromZonedTime(value, userTimezone) antes de enviar à API
// 3. A API recebe e armazena em UTC

A Temporal API é o futuro do JavaScript para manipulação de datas — com suporte nativo a timezones, durações e calendários. Ainda está em Stage 3 no TC39 (2024). Quando chegar ao Stage 4 e ter suporte amplo, será a substituição definitiva para date-fns. Enquanto isso, date-fns + date-fns-tz é o stack recomendado.

Conclusão

O modelo correto é simples: UTC no transporte e armazenamento, timezone local apenas na exibição. O date-fns facilita a lógica de negócio com funções imutáveis e tree-shakeable. O date-fns-tz resolve conversões explícitas quando você precisa. E parseISO ao invés de new Date(string) evita a classe mais comum de bugs com datas no JavaScript.