La fecha sale un día antes en JavaScript: toISOString

Guardas o muestras una fecha en JavaScript y el día sale equivocado: una venta registrada a las 19:17 aparece con la fecha de mañana, o un <input type="date"> guarda el día anterior al que elegiste. En una máquina con zona horaria UTC-5 puedes reproducirlo así:

new Date('2026-08-18T19:17:00-05:00').toISOString().slice(0, 10)
// '2026-08-19'  (se esperaba '2026-08-18')

new Date('2026-07-09').getDate()
// 8             (se esperaba 9)

Le pasa a quien tiene usuarios en una zona horaria distinta de UTC y mezcla toISOString(), new Date('YYYY-MM-DD') y getters locales como getDate(). Aquí verás la causa exacta y una forma de manejar fechas que no depende de la zona horaria del servidor.

Por qué pasa

Intervienen dos reglas de JavaScript que parecen inofensivas por separado:

  • toISOString() siempre devuelve la hora en UTC. MDN lo resume así: la zona horaria es siempre UTC, indicada por el sufijo Z.
  • Un string de solo fecha (YYYY-MM-DD) se interpreta como medianoche UTC, mientras que un string con fecha y hora sin desfase (2019-01-01T00:00:00) se interpreta en la zona local. Esa diferencia está documentada en la página de Date.parse() de MDN.

En UTC-5, las 19:00 locales son las 00:00 UTC del día siguiente. Por eso una venta de las 19:17 sale con la fecha de mañana si la formateas con toISOString(). Y al revés: new Date('2026-07-09') es el 9 de julio a las 00:00 UTC, que en tu zona local es el 8 de julio a las 19:00. Si luego lees el día con getDate() o toLocaleDateString(), obtienes el 8.

En zonas al este de UTC ocurre lo contrario: las horas de la madrugada local caen en el día anterior de UTC. Y en un servidor configurado en UTC no verás el problema, que es justo lo que lo hace tan fácil de pasar por alto: funciona en el servidor y falla en el navegador del usuario (o al revés).

Solución

Primero distingue qué tipo de valor tienes, porque se tratan distinto:

  • Instante: un momento exacto (la hora de una venta, un createdAt). Se debe formatear en la zona horaria del negocio o del usuario.
  • Fecha pura: un día de calendario sin hora (un cumpleaños, una fecha de vencimiento, el valor de un <input type="date">). No debería pasar por UTC.
  1. Instantes: formatea en una zona explícita. Con date-fns-tz (instala date-fns y date-fns-tz):

    import { formatInTimeZone } from 'date-fns-tz';
    
    const TZ = 'America/Bogota'; // zona de ejemplo con UTC-5
    
    export function fechaLocal(instante: Date, tz: string = TZ): string {
      return formatInTimeZone(instante, tz, 'yyyy-MM-dd');
    }
    
    fechaLocal(new Date('2026-08-19T00:17:00Z')); // '2026-08-18'

    Sin dependencias, Intl.DateTimeFormat con la configuración regional en-CA produce el formato año-mes-día:

    new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Bogota' })
      .format(new Date('2026-08-19T00:17:00Z')); // '2026-08-18'

    Ese formato depende de los datos de localización del entorno, así que protege esta función con un test (paso 4).

  2. Fechas puras: no las conviertas a UTC. Lo más seguro es conservar el string YYYY-MM-DD tal cual, tanto en el formulario como al enviarlo a la API. Si necesitas un objeto Date, créalo con los componentes, que se interpretan en hora local (y el mes empieza en 0):

    const [y, m, d] = '2026-07-09'.split('-').map(Number);
    const fecha = new Date(y, m - 1, d); // 9 de julio, medianoche local
    fecha.getDate(); // 9

    Si ya tienes fechas puras guardadas como medianoche UTC, no las recalcules: muéstralas indicando la zona UTC.

    formatInTimeZone(new Date('2026-07-09'), 'UTC', 'yyyy-MM-dd'); // '2026-07-09'
  3. Rangos por día: convierte los extremos desde la zona del negocio. fromZonedTime interpreta un string sin zona como hora local de la zona indicada y devuelve el instante UTC equivalente:

    import { fromZonedTime } from 'date-fns-tz';
    
    export function rangoDelDia(fecha: string, tz: string = TZ) {
      return {
        desde: fromZonedTime(`${fecha}T00:00:00`, tz),
        hasta: fromZonedTime(`${fecha}T23:59:59.999`, tz),
      };
    }
    
    rangoDelDia('2026-08-18');
    // desde: 2026-08-18T05:00:00.000Z
    // hasta: 2026-08-19T04:59:59.999Z

    Con ese rango, una consulta por día incluye las ventas de las 19:17 en lugar de moverlas al día siguiente.

  4. Agrega un test con una hora entre las 19:00 y las 23:59. Es el caso que rompe el código ingenuo. Como la función recibe la zona de forma explícita, el test no depende de la configuración de la máquina:

    import { describe, expect, it } from 'vitest';
    import { fechaLocal } from './fecha';
    
    describe('fechaLocal', () => {
      it('no adelanta el día entre las 19:00 y las 23:59 (UTC-5)', () => {
        expect(fechaLocal(new Date('2026-08-19T00:17:00Z'))).toBe('2026-08-18');
      });
    
      it('a la medianoche local ya es el día siguiente', () => {
        expect(fechaLocal(new Date('2026-08-19T05:00:00Z'))).toBe('2026-08-19');
      });
    });

Cómo verificar que quedó resuelto

  • Ejecuta la suite de tests: los dos casos de arriba deben pasar (2026-08-19T00:17Z debe dar 2026-08-18).
  • Compara ambos enfoques en una consola Node.js. En una máquina en UTC-5 verás: toISOString().slice(0, 10) da 2026-08-19, mientras que fechaLocal(...) da 2026-08-18.
  • Prueba el flujo completo con una venta creada entre las 19:00 y las 23:59: la fecha en pantalla, en la exportación a Excel o PDF y en el filtro por día debe ser la misma.
  • Elige una fecha en el <input type="date">, guárdala, recarga y comprueba que sigue siendo el mismo día.

Variantes y errores relacionados

Agregar "T00:00:00" cambia el significado del string

new Date('2026-07-09') es UTC, pero new Date('2026-07-09T00:00:00') es hora local. Según la zona, la diferencia entre ambos es de varias horas (5 horas en UTC-5). No lo uses como parche sin saber cuál de los dos necesitas.

Funciones del paquete con nombres antiguos

Si encuentras ejemplos con zonedTimeToUtc o utcToZonedTime, son los nombres anteriores a la versión 3 de date-fns-tz: pasaron a llamarse fromZonedTime y toZonedTime, según las notas de la versión 3.0.0.

Si prefieres no depender de en-CA

function fechaISO(instante: Date, timeZone: string): string {
  const partes = new Intl.DateTimeFormat('en-US', {
    timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
  }).formatToParts(instante);
  const valor = (tipo: string) => partes.find((p) => p.type === tipo)!.value;
  return `${valor('year')}-${valor('month')}-${valor('day')}`;
}

No mezcles los dos tipos de valor

Una fecha pura que se guardó como medianoche UTC y luego se convierte a la zona local retrocede un día. Decide desde el modelo de datos si cada campo es un instante o una fecha pura, y mantén esa decisión de punta a punta: formulario, API, base de datos y exportaciones.

Resumen

  • toISOString() formatea siempre en UTC, y new Date('YYYY-MM-DD') es medianoche UTC; en UTC-5 eso cambia el día a las 19:00 locales.
  • Para instantes, formatea con una zona explícita: formatInTimeZone o Intl.DateTimeFormat con timeZone.
  • Para fechas puras, conserva el string YYYY-MM-DD o crea el Date con new Date(y, m - 1, d).
  • Para rangos por día, usa fromZonedTime desde T00:00:00 hasta T23:59:59.999.
  • Cubre el caso con un test entre las 19:00 y las 23:59.

Referencias

Comentarios