Даты в JavaScript: чек-лист перед продакшеном
new Date('2024-03-01') и new Date('2024-3-1') дают разное. Часовые пояса, парсинг, DST и мутабельность Date ломают продакшен тихо. Разбираем, что проверить до релиза.

Классический баг: пользователь в Москве выбирает дату рождения 1 марта, на сервере она сохраняется как 28 февраля. Причина — new Date('2024-03-01') парсится как полночь UTC, а при выводе через toLocaleDateString() сдвигается на локальный пояс назад. Ниже — чек-лист по тем местам работы с датами в JavaScript, которые чаще всего пробивают в проде. Актуально для окружений с ES2020+ и Node 18+; отдельно отмечено, что даёт Temporal, который на конец 2024 года всё ещё в статусе Stage 3.
Парсинг строк: половина багов рождается здесь
Конструктор Date и Date.parse() ведут себя по-разному в зависимости от формата строки, и это закреплено в спецификации, а не является багом движка.
- ISO-формат с датой без времени —
'2024-03-01'трактуется как UTC-полночь. Это требование спецификации ECMAScript. - ISO-формат с временем без зоны —
'2024-03-01T12:00:00'в современных движках трактуется как локальное время. Раньше поведение отличалось между браузерами. - Не-ISO форматы —
'2024/03/01','March 1, 2024'— парсятся как локальное время, но результат зависит от реализации. Спецификация их не гарантирует.
Единственный формат, который спецификация обязывает парсить одинаково везде, — ISO 8601. Всё остальное — лотерея, в которой призом бывает сдвиг на сутки.
Практический вывод: не полагайтесь на встроенный парсинг для пользовательского ввода и любых форматов, кроме ISO. Разбирайте строку явно.
// Явный разбор "дата без времени" как локальной даты,
// а не как UTC-полуночи
function parseLocalDate(dateStr) {
const [year, month, day] = dateStr.split('-').map(Number);
// month в конструкторе Date считается с нуля
return new Date(year, month - 1, day);
}
parseLocalDate('2024-03-01'); // локальная полночь 1 марта, без сдвига
Часовые пояса: где именно происходит сдвиг
Объект Date внутри хранит одно число — миллисекунды от эпохи Unix в UTC. Пояс появляется только при чтении: getHours() отдаёт локальный час, getUTCHours() — UTC. Ошибки возникают, когда парсинг и вывод используют разные системы отсчёта.
Три правила, которые снимают большинство проблем:
- Храните и передавайте в UTC. На бэкенд и в базу — ISO-строка с
Zили timestamp. Не отправляйтеtoLocaleString(). - Конвертируйте в локальный пояс только для показа. И только на том устройстве, где показываете.
- Если нужен конкретный чужой пояс (показать время события в часовом поясе площадки, а не пользователя) — используйте
Intl.DateTimeFormatс опциейtimeZone.
// Показать один и тот же момент времени в разных поясах
const moment = new Date('2024-03-01T09:00:00Z');
new Intl.DateTimeFormat('ru-RU', {
timeZone: 'Europe/Moscow',
dateStyle: 'short',
timeStyle: 'short',
}).format(moment); // 01.03.2024, 12:00
new Intl.DateTimeFormat('ru-RU', {
timeZone: 'Asia/Vladivostok',
dateStyle: 'short',
timeStyle: 'short',
}).format(moment); // 01.03.2024, 19:00
Список названий поясов — база IANA (Europe/Moscow, а не MSK и не смещение +03:00). Смещение меняется из-за перехода на летнее время и политических решений, а имя зоны — нет.
DST: арифметика дат врёт на границах перехода
В поясах с летним временем сутки бывают длиной 23 или 25 часов. Прибавление «одного дня» через миллисекунды на такой границе даёт неверный результат.
// Неверно для поясов с переходом на летнее время:
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
// Верно — операция над календарными компонентами:
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
Россия не переходит на летнее время с 2014 года, поэтому внутри РФ этот баг может не проявиться. Но он выстрелит, как только среди пользователей окажется Европа или США. Тестируйте на данных с DST-поясом, даже если ваш продукт «для России».
DST (Daylight Saving Time) — сезонный перевод часов. В момент перехода одно и то же локальное время может не существовать или встречаться дважды.
Мутабельность: Date меняется на месте
Методы вида setDate, setMonth, setHours изменяют сам объект и возвращают timestamp, а не новый Date. Если исходную дату где-то держат по ссылке, вы её испортите.
const start = new Date('2024-03-01T00:00:00Z');
const end = start; // это та же ссылка, не копия
end.setMonth(end.getMonth() + 1);
console.log(start.toISOString()); // 2024-04-01 — start тоже сдвинулся
// Копия перед мутацией:
const safeEnd = new Date(start);
Валидация: Invalid Date не бросает исключение
Невалидная строка не даёт ошибку — она даёт объект Date, у которого getTime() возвращает NaN. Такой объект спокойно проходит дальше по коду и падает позже, в неожиданном месте.
function isValidDate(value) {
return value instanceof Date && !Number.isNaN(value.getTime());
}
isValidDate(new Date('не дата')); // false
isValidDate(new Date('2024-03-01')); // true
Стоит ли тянуть библиотеку
Голый Date закрывает базовые задачи, если дисциплинированно разбирать ввод и хранить UTC. Библиотеки нужны, когда много арифметики, поясов и форматирования.
| Вариант | Что даёт | Чем платите |
|---|---|---|
| Нативный Date + Intl | Ноль зависимостей, форматирование через Intl | Ручной парсинг, мутабельность, ловушки DST на вас |
| date-fns | Иммутабельные функции, tree-shaking, работа поясов через отдельный пакет | Своя зависимость и её обновления |
| Luxon | Первоклассная работа с IANA-поясами и DST | Больше размер бандла, свой API объекта |
| Moment.js | Огромная экосистема | Автор проекта объявил его legacy; для нового кода не рекомендуют |
Отдельно — Temporal: новый встроенный API с иммутабельными объектами, явными типами для «дата без времени», «время без даты» и «момент в поясе». На конец 2024 года это предложение уровня Stage 3, в браузерах и Node доступно через полифил, а не как стабильный глобал. Планировать под него можно, закладывать в прод как готовый — рано; сверяйте статус по документации TC39.
Чек-лист перед релизом
- Весь пользовательский ввод дат разбирается явно, а не отдаётся конструктору
Date«как есть». - На бэкенд и в хранилище уходит UTC (ISO с
Zили timestamp), а не локализованная строка. - Прибавление дней/месяцев сделано через
setDate/setMonth, а не арифметикой миллисекунд. - Есть проверка на
Invalid Dateсразу после разбора. - Показ в чужом поясе — через
Intl.DateTimeFormatс именем зоны IANA. - Тесты гоняются хотя бы на одном DST-поясе, а не только на московском времени.
- Даты не мутируются по ссылке — перед
set*делается копияnew Date(orig).
Что настроить дальше
Заведите в тестах фиксированный пояс через переменную окружения TZ (например, TZ='America/New_York') — так вы поймаете DST-баги в CI, а не в проде. Для критичных к времени сервисов синхронизируйте серверное время по NTP и логируйте моменты в UTC. Если объём работы с поясами растёт, оцените миграцию на Temporal через полифил, отслеживая продвижение предложения по стадиям TC39.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Date — JavaScript | MDN, TC39 Temporal proposal
Частые вопросы
Почему new Date('2024-03-01') даёт вчерашнюю дату при выводе?
А если у меня старая версия Node или устаревший браузер?
Что делать, если конвертация в чужой пояс не сработала?
Можно обойтись без библиотек дат?
Стоит ли уже переходить на Temporal?
Как ловить баги с часовыми поясами в CI, если у нас всё на московском времени?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.