Tech Stack: TypeScript (Frontend) + Drizzle ORM (Backend) + PostgreSQL (Database)
For a more detailed, story-driven version of this guide, check out my article on dev.to: The Developer's Guide to Never Messing Up Time Zones Again
- Introduction
- Core Principles
- Library Setup
- PostgreSQL + Drizzle ORM Setup
- Frontend Time Handling (TypeScript)
- Handling Date Picker Input
- Solving the 'Broken Date Filter' Nightmare
- User Time Zone Config & Multi-Tenant Support
- PDF Generation and Time Zone Safety
- Standard Formatting Functions
- Example Time Zone Conversions
- References & Libraries
- FAQ
Time zone inconsistencies cause bugs in scheduling, reporting, and UI rendering. This guide ensures all time-related logic is safe, predictable, and localized β using UTC as the source of truth.
- β Store all timestamps in UTC.
- β Always convert timestamps to user's preferred time zone for display.
- β Never rely on server/browser local time during rendering.
- β Use strict global formatting functions.
Install:
npm install date-fns date-fns-tz
Recommended Imports:
import { formatInTimeZone, zonedTimeToUtc } from 'date-fns-tz';
Optionally explore:
Intl.DateTimeFormat
,Temporal
created_at TIMESTAMPTZ DEFAULT (now() AT TIME ZONE 'UTC'),
updated_at TIMESTAMPTZ DEFAULT (now() AT TIME ZONE 'UTC')
import { timestamp, varchar } from 'drizzle-orm/pg-core';
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
timeZone: varchar('time_zone', { length: 100 }).default('Asia/Kolkata'),
export function formatDateOnly(date: Date, timeZone: string): string {
return formatInTimeZone(date, timeZone, 'dd-MM-yyyy');
}
export function formatDateTime12Hr(date: Date, timeZone: string): string {
return formatInTimeZone(date, timeZone, 'dd-MM-yyyy hh:mm a');
}
const localTime = formatDateTime12Hr(new Date(utcDate), user.timeZone);
Date pickers typically return values in the browser's local time zone. Without conversion, saving these directly will create wrong UTC timestamps.
- Convert the local date picker value into UTC before saving to DB.
import { zonedTimeToUtc } from 'date-fns-tz';
const userInput = '2025-01-01T10:00';
const userTimeZone = user.timeZone;
const utcDate = zonedTimeToUtc(userInput, userTimeZone); // save this to DB
Ensure this logic happens in the frontend form handler before sending to backend.
This is where our principles fix the most common and frustrating time zone bugs. We'll solve two classic problems: filtering for a single day and filtering for a date range.
This common approach creates a filter for a UTC day, not the user's local day, leading to missing data.
// This is a TRAP! It will not match the user's local day.
const selectedDay = "2025-07-19";
const startOfDayUTC = new Date(selectedDay);
startOfDayUTC.setUTCHours(0, 0, 0, 0); // Start of the day in UTC
const endOfDayUTC = new Date(selectedDay);
endOfDayUTC.setUTCHours(23, 59, 59, 999); // End of the day in UTC
const condition = and(
gte(posts.createdAt, startOfDayUTC),
lte(posts.createdAt, endOfDayUTC)
);
We create a precise UTC range that perfectly matches the user's single local day.
import { zonedTimeToUtc } from 'date-fns-tz';
import { addDays } from 'date-fns';
import { and, gte, lt } from 'drizzle-orm';
const selectedDayStr = "2025-07-19";
const userTimeZone = "Asia/Kolkata";
const startOfDayInUtc = zonedTimeToUtc(selectedDayStr, userTimeZone);
const startOfNextDayInUtc = zonedTimeToUtc(
addDays(new Date(selectedDayStr), 1),
userTimeZone
);
const filterCondition = and(
gte(posts.createdAt, startOfDayInUtc),
lt(posts.createdAt, startOfNextDayInUtc)
);
This has the same flaw, using the start of the UTC "from" day and the end of the UTC "to" day.
// This is also a TRAP! It misses data at both ends of the range.
if (fromDate) {
const fromDateObj = new Date(fromDate);
fromDateObj.setUTCHours(0, 0, 0, 0);
conditions.push(gte(posts.createdAt, fromDateObj));
}
if (toDate) {
const toDateObj = new Date(toDate);
toDateObj.setUTCHours(23, 59, 59, 999);
conditions.push(lte(posts.createdAt, toDateObj));
}
We find the start of the user's local "from" day and the start of the day after their "to" day.
import { zonedTimeToUtc } from 'date-fns-tz';
import { addDays } from 'date-fns';
import { and, gte, lt } from 'drizzle-orm';
const fromDateStr = "2025-07-01";
const toDateStr = "2025-07-31";
const userTimeZone = "Asia/Kolkata";
const startRangeUtc = zonedTimeToUtc(fromDateStr, userTimeZone);
const nextDayAfterToDate = addDays(new Date(toDateStr), 1);
const endRangeUtc = zonedTimeToUtc(nextDayAfterToDate, userTimeZone);
const filterCondition = and(
gte(posts.createdAt, startRangeUtc),
lt(posts.createdAt, endRangeUtc)
);
timeZone: varchar("time_zone", { length: 100 }).default('Asia/Kolkata')
const userTz = user.timeZone;
formatDateTime12Hr(new Date(timestamp), userTz);
β Multi-tenant mode: store org-level time zone fallback. β Give UI setting for user to update their preferred time zone.
π Problem: Servers often run in different time zones. new Date()
may produce unexpected results during PDF generation.
β Solution:
- Pre-format timestamps in userβs time zone before generating the PDF.
- Do not rely on serverβs locale.
const displayTime = formatDateTime12Hr(new Date(data.createdAt), user.timeZone);
π Only 2 global functions allowed in codebase:
formatDateOnly()
βDD-MM-YYYY
formatDateTime12Hr()
βDD-MM-YYYY HH:MM AM/PM
β‘οΈ All UI, exports, and PDFs must use these β no inline formatting allowed.
- US Central:
10:00 AM
(UTC-6) - β‘ UTC:
10:00 AM + 6h = 4:00 PM UTC
- β‘ IST:
4:00 PM + 5:30 = 9:30 PM IST
const utc = zonedTimeToUtc('2025-01-01 10:00', 'America/Chicago');
const ist = formatInTimeZone(utc, 'Asia/Kolkata', 'dd-MM-yyyy hh:mm a');
- IST:
10:00 AM
(UTC+5:30) - β‘ UTC:
10:00 AM - 5:30 = 4:30 AM UTC
- β‘ US Central:
4:30 AM - 6h = 10:30 PM (prev day)
const utc = zonedTimeToUtc('2025-01-01 10:00', 'Asia/Kolkata');
const cst = formatInTimeZone(utc, 'America/Chicago', 'dd-MM-yyyy hh:mm a');
Q: Why store time in UTC?
Ensures consistency across regions, avoids DST bugs, simplifies backend logic.
Q: Should I ever store local time?
No. Always store UTC, format to local.
Q: How do I change display format globally?
Update
formatDateOnly
andformatDateTime12Hr
only. Never format inline.
Q: What about scheduled jobs?
Always convert cron times to UTC before storing and scheduling.
Keep your time logic predictable, safe, and local to your users. π