Handling Timestamps in Web Applications Across Time Zones

Time zone-related bugs are some of the trickiest issues in modern web applications. You think you're sending "June 30th" to the backend, but it shows up as "June 29th". Or you filter data by a single day and get partial results or results from the wrong day entirely.

This article walks you through a robust, modern approach to handling timestamps in the frontend when your users are in different time zones. We'll use date-fns v4 and its first-class time zone support via @date-fns/tz.

The Problem: Local Dates Aren't Global

Your app allows users to select a date (e.g., June 30). A naive implementation might be:

const timestamp = new Date('2025-06-30').getTime() / 1000

But new Date('2025-06-30') gets interpreted as midnight in your system time zone, which means this timestamp will vary by client. If your backend stores data in UTC or filters by UTC-day boundaries, you'll get incorrect results for users in other time zones.

The Solution: Explicit Time Zones with TZDate

To avoid surprises, treat date-only values (2025-06-30) as time zone-ambiguous, and always resolve them into a full timestamp in the user's time zone.

Step 1: Convert to TZDate

import { TZDate } from '@date-fns/tz'

const userTz = 'America/New_York'
const rawDate = '2025-06-30'
const tzDate = new TZDate(`${rawDate}T00:00:00`, userTz)

Now you have a precise representation of the start of June 30 in the user’s time zone.

Step 2: Send Timestamps to the Backend

Once you have the TZDate, convert it to a Unix timestamp:

const unixTimestamp = Math.floor(tzDate.getTime() / 1000)

Alternatively, use .toISOString() if your API prefers ISO strings.

Why Not Use startOfDay()?

You can, but be careful:

startOfDay(new TZDate(...))

This works only if the TZDate is initialized correctly. But doing startOfDay(new Date('2025-06-30')) will silently break — it runs startOfDay() in the wrong time zone (likely UTC or local system zone).

Safer Alternative: Construct It Yourself

const tzDate = new TZDate(`${rawDate}T00:00:00`, userTz)
const endDate = new TZDate(`${rawDate}T23:59:59`, userTz)

Don’t rely on auto-inference when working with plain Date objects — especially not when you're dealing with filtering, logs, or anything user-facing.

Creating a Payload from a Date Picker

Assuming you have a date picker that returns a date string (e.g., "2025-06-30") and the user's time zone, you can create a payload like this:

function generatePayload(dateString: string, userTz: string) {
  const tzDate = new TZDate(`${dateString}T00:00:00`, userTz)

return {
  original: dateString,
  timezone: userTz,
  utcIso: tzDate.toISOString(),
  unixTimestamp: Math.floor(tzDate.getTime() / 1000),
  }
}

Bonus: Pretty Formatting for Display

You can format using the same time zone-aware logic:

import { format } from 'date-fns'
import { TZDate } from '@date-fns/tz'

function formatDateForUser(date: Date, tz: string, formatStr = 'PPP') {
  const tzDate = new TZDate(date, tz)
  return format(tzDate, formatStr)
}

What if You Care About Hours Too?

If you're building scheduling features, you’ll likely be working with full datetime strings — not just dates.
The approach is similar:

function generatePayloadWithTime(input: string | Date | number, userTz: string) {
  let date: Date

  if (typeof input === 'number') {
    date = new Date(input * 1000)
  } else if (typeof input === 'string') {
    date = new Date(input)
  } else {
    date = input
  }

  const tzDate = new TZDate(date, userTz)

  return {
    original: date.toISOString(),
    timezone: userTz,
    utcIso: tzDate.toISOString(),
    unixTimestamp: Math.floor(tzDate.getTime() / 1000),
  }
}

This lets you capture and transmit full time-specific data like "2025-11-03T14:00:00" reliably in the user's intended time zone.

Conclusion

Time zone problems aren’t going away. But with the right tools and mindset, you can make your app behave consistently and predictably for users all over the world. Always work with explicit time zones, prefer TZDate, and avoid using raw Date unless you're 100% sure what it represents.

By following this strategy, you'll eliminate subtle bugs and make date filtering and reporting bulletproof across the globe.