Skip to content
← Back to snippets
JavaScriptPHP

JavaScript dates and timezones without the pain

Six snippets for the timezone problems that actually occur: detecting the visitor's zone, formatting in an arbitrary IANA zone, round-tripping YYYY-MM-DD without shifting a day, and computing the seconds left until UTC rolls over.

Date bugs are rarely bugs in date arithmetic. They are almost always a mismatch between three different things a program calls "a date": an instant in time, a calendar date with no time attached, and a wall-clock reading in some zone. Mix two of them in one variable and you get the off-by-one-day bug that only appears for users west of Greenwich, or only in summer.

The classic: a date that moves by one day

new Date('2026-09-04') is parsed as UTC midnight, but getFullYear(), getMonth() and getDate() read it in the machine's local zone. For anyone at a negative UTC offset, that combination reports the third of September. The same trap runs in the other direction on the way out: toISOString() converts back through UTC, so a date picked as "today" in Sydney can serialise as yesterday.

A calendar date has no timezone. The fix is to never let it acquire one — either keep it as a string, or construct and read it consistently in the same zone.

Formatting in a zone that is not yours

Intl.DateTimeFormat accepts a timeZone option and does the whole job, including the historical rules: it knows which weeks in 2026 a given zone spends on DST, because it reads the IANA time zone database that ships with the runtime. That matters because DST rules are politics, not physics — countries change them with a few months' notice, and any offset you hardcode has an expiry date.

Intl.DateTimeFormat().resolvedOptions().timeZone gives you the visitor's IANA zone identifier directly. It is a much better default than asking, and much better than getTimezoneOffset(), which returns only the current offset and cannot tell Europe/Berlin from Africa/Lagos in winter.

Offsets are a display concern, not a storage one

Showing a user "UTC+3" next to a zone name is useful. Storing "UTC+3" is not: it loses which zone it came from and becomes wrong twice a year. Store the IANA identifier, compute the offset for display, and recompute it whenever you render.

The last snippet — seconds until the next UTC day or month boundary — is the one that turns up in cache expiry and daily-reset logic, where the reset must happen at the same instant for every user regardless of where they are.

One snippet here is PHP rather than JavaScript: the offset problem is identical on the server, and the two implementations are worth reading side by side.

JavaScriptJavaScript

Detect the visitor's timezone automatically

One line to get the visitor's IANA timezone (e.g. "Asia/Jerusalem") straight from the browser, no geolocation permission needed.

How it works: Intl.DateTimeFormat().resolvedOptions() reads the timezone the OS/browser is configured with — it works everywhere modern JS runs and never triggers a navigator.geolocation permission prompt.

function getVisitorTimezone() {
  return Intl.DateTimeFormat().resolvedOptions().timeZone
}

// Example usage
getVisitorTimezone() // "Europe/Berlin", "Asia/Jerusalem", etc.
JavaScriptJavaScript

Format a date/time in any IANA timezone

Renders a date as if you were standing in a specific timezone, regardless of the visitor's own local time.

How it works: passing a timeZone to Intl.DateTimeFormat makes it compute the wall-clock time for that zone directly — no manual offset math, and it automatically accounts for daylight saving time.

function formatInTimezone(date, timeZone, options = {}) {
  return new Intl.DateTimeFormat('en-US', {
    year: 'numeric', month: 'short', day: '2-digit',
    hour: '2-digit', minute: '2-digit',
    ...options,
    timeZone
  }).format(date)
}

// Example usage — same instant, two different timezones
const now = new Date()
formatInTimezone(now, 'Asia/Tokyo')       // "Jul 9, 14:32"
formatInTimezone(now, 'America/New_York') // "Jul 9, 01:32"
JavaScriptJavaScript

Convert Date to/from "YYYY-MM-DD" without timezone shifts

date.toISOString().slice(0,10) silently shifts to UTC and can show yesterday's date near midnight — these two helpers stay in local time.

How it works: instead of going through toISOString() (which converts to UTC first), both helpers read/construct the date from local getFullYear/getMonth/getDate components directly, so the calendar day never shifts because of the visitor's timezone.

function toIsoDateString(date) {
  const y = date.getFullYear()
  const m = String(date.getMonth() + 1).padStart(2, '0')
  const d = String(date.getDate()).padStart(2, '0')
  return `${y}-${m}-${d}`
}

function parseIsoDate(value) {
  const match = String(value || '').match(/^(\d{4})-(\d{2})-(\d{2})/)
  if (!match) return null
  return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
}

// Example usage
toIsoDateString(new Date()) // "2026-07-08", using local time, not UTC
parseIsoDate('2026-07-08')  // Date for July 8, 2026 at local midnight
JavaScriptJavaScript

Get seconds until the next UTC day/month rollover

Computes how many seconds remain until midnight UTC (or the 1st of next month) — the exact TTL you want on a Redis key for a daily/monthly quota.

How it works: it builds the exact UTC timestamp for the next day/month boundary with Date.UTC(...), then subtracts the current time — no timezone guesswork, since everything stays in UTC.

function secondsUntilNextUtcDay(date = new Date()) {
  const nextDay = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + 1))
  return Math.floor((nextDay.getTime() - date.getTime()) / 1000)
}

function secondsUntilNextUtcMonth(date = new Date()) {
  const nextMonth = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1))
  return Math.floor((nextMonth.getTime() - date.getTime()) / 1000)
}

// Example usage — set a Redis quota key to expire exactly at UTC midnight
await redis.set(`quota:daily:${userId}`, count, 'EX', secondsUntilNextUtcDay())
JavaScriptJavaScript

Display a timezone with its GMT offset

Turns an IANA timezone name into a friendly label like "(GMT+02:00) Asia/Jerusalem" for timezone pickers.

How it works: Intl.DateTimeFormat with timeZoneName: 'shortOffset' returns a part like "GMT+3"; the offset is then normalized to a fixed +HH:MM format so every label lines up in a dropdown list.

function formatTzWithGmt(timeZone, date = new Date()) {
  const parts = new Intl.DateTimeFormat('en-US', {
    timeZone,
    timeZoneName: 'shortOffset'
  }).formatToParts(date)

  const raw = parts.find((p) => p.type === 'timeZoneName')?.value || 'GMT'
  let offset = raw.replace('GMT', '') || '+00:00'
  if (!offset.includes(':')) {
    const sign = offset.startsWith('-') ? '-' : '+'
    offset = `${sign}${offset.replace(/[+-]/, '').padStart(2, '0')}:00`
  }

  return `(GMT${offset}) ${timeZone}`
}

// Example usage
formatTzWithGmt('Asia/Jerusalem') // "(GMT+03:00) Asia/Jerusalem"
PHP

Get a timezone's current GMT offset in PHP

Turns an IANA timezone name into a friendly label like "(GMT+02:00) Asia/Jerusalem", accounting for daylight saving time.

How it works: DateTimeZone::getOffset() returns the offset in seconds for a specific point in time, so passing today's date automatically accounts for whether DST is currently in effect.

<?php

function formatTzWithGmt(string $timezone, ?DateTimeImmutable $date = null): string
{
    $date ??= new DateTimeImmutable('now');
    $tz = new DateTimeZone($timezone);

    $offsetSeconds = $tz->getOffset($date);
    $sign = $offsetSeconds >= 0 ? '+' : '-';
    $hours = str_pad((string) intdiv(abs($offsetSeconds), 3600), 2, '0', STR_PAD_LEFT);
    $minutes = str_pad((string) (intdiv(abs($offsetSeconds), 60) % 60), 2, '0', STR_PAD_LEFT);

    return "(GMT{$sign}{$hours}:{$minutes}) {$timezone}";
}

// Example usage
formatTzWithGmt('Asia/Jerusalem');   // "(GMT+03:00) Asia/Jerusalem"
formatTzWithGmt('America/New_York'); // "(GMT-04:00) America/New_York" during DST
// CONNECT

Contact Information

Submit a request or write to me directly on Telegram. I'll reply within 24 hours.

Provide either your email or Telegram so I can reply.