The Developer's Guide to Never Messing Up Time Zones Again: A TypeScript, Drizzle, and PostgreSQL Journey
Jackson Kasi

Jackson Kasi @jacksonkasi

About: Self-taught tech enthusiast with a passion for continuous learning and innovative solutions

Location:
India
Joined:
Dec 25, 2020

The Developer's Guide to Never Messing Up Time Zones Again: A TypeScript, Drizzle, and PostgreSQL Journey

Publish Date: Jul 19
4 2

Hey everyone! Jackson Kasi here 👋.

Ever faced these developer nightmares?

  • The Broken Date Filter 📅❌

    • Your user picks a date range: "July 1st to July 31st".
    • The bug: The report misses the last few hours of data from July 31st.
  • The Two-Faced Date 💾≠🖥️

    • In your database, you see: createdAt: 2025-07-20 02:30:00
    • On the frontend, your user sees: "Posted on July 19th, 2025"

These aren't just random glitches; they're classic time zone traps that can corrupt your data and make your application feel broken.

After wrestling with these exact issues myself, I developed a personal, bulletproof strategy that I now use for all my projects. This guide is that framework—my system for ensuring time-related logic is safe, predictable, and correct for every user, everywhere.


1. 📐 The Three Golden Rules of Time: My Unwavering Principles

Before we touch any code, we need a solid foundation. These are the three rules I live by to prevent chaos.

  • ✅ Rule #1: Store All Timestamps in UTC.
    This is the cardinal rule. Universal Coordinated Time (UTC) is the global standard. It doesn’t observe Daylight Saving Time, making it the perfect, unambiguous source of truth. By storing all timestamps in UTC, you ensure every event refers to the exact same moment in time, no matter where your users or servers are.

  • ✅ Rule #2: Display All Timestamps in the User's Local Time Zone.
    While UTC is for storage, local time is for human understanding. Users expect to see times relevant to their location. This conversion should happen at the last possible moment—typically on the frontend, just before rendering.

  • ✅ Rule #3: Centralize and Standardize Formatting Functions.
    Consistency is key. To avoid a mess of different date formats, centralize all time formatting into a few, well-defined functions. This promotes reusability and ensures every timestamp displayed to the user is consistent.


2. 📦 Library Setup

We use date-fns-tz because it's reliable, lightweight, and handles complexities like Daylight Saving Time perfectly.

Install it:

npm install date-fns date-fns-tz
Enter fullscreen mode Exit fullscreen mode

Recommended Imports:

// These two functions will handle most of your needs
import { formatInTimeZone, zonedTimeToUtc } from 'date-fns-tz';
Enter fullscreen mode Exit fullscreen mode

3. 🏛 The Database: Your Immutable Time Capsule (PostgreSQL + Drizzle)

Your database must be configured to respect our golden rules. In PostgreSQL, TIMESTAMPTZ is your best friend. It automatically converts incoming timestamps to UTC for storage, preserving our single source of truth.

Drizzle Schema (/db/schema.ts)

We use withTimezone: true, which maps to PostgreSQL's TIMESTAMPTZ data type.

import { pgTable, serial, timestamp, varchar } from 'drizzle-orm/pg-core';

// In your users table, we need a column to store their time zone preference
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  // ... other user fields
  // This allows us to convert UTC time back to the user's local time
  timeZone: varchar('time_zone', { length: 100 }).default('Asia/Kolkata').notNull(),
});

// In any table that needs timestamps
export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  // ... other post fields
  createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
Enter fullscreen mode Exit fullscreen mode

4. 💻 The Frontend: Your User's Window to Time (TypeScript + date-fns-tz)

The frontend is where we translate our UTC timestamps into something human-readable.

Centralized Formatting Utilities (/utils/date.ts)

This is where we enforce Rule #3. All formatting logic lives here.

import { formatInTimeZone } from 'date-fns-tz';

/**
 * Formats a date to show only the date part, like "19-07-2025"
 * @example formatDateOnly(new Date(), 'America/New_York') // "19-07-2025"
 */
export function formatDateOnly(date: Date | string, timeZone: string): string {
  return formatInTimeZone(new Date(date), timeZone, 'dd-MM-yyyy');
}

/**
 * Formats a date to show date and 12-hour time, like "19-07-2025 02:30 PM"
 * @example formatDateTime12Hr(new Date(), 'Asia/Kolkata') // "19-07-2025 09:30 PM"
 */
export function formatDateTime12Hr(date: Date | string, timeZone: string): string {
  return formatInTimeZone(new Date(date), timeZone, 'dd-MM-yyyy hh:mm a');
}
Enter fullscreen mode Exit fullscreen mode

The "Two Functions" Rule: Why Less Is More

I strictly enforce what I call the "Two Functions" Rule. Think of it like a design system. You don't use random spacing; you use predefined tokens (space-4, space-8). These two functions are your time tokens. They ensure every timestamp in the UI is consistent, reducing cognitive load for developers and creating a predictable experience for users. Any deviation must be a conscious, documented decision.


5. 📆 Handling Date Picker Input

This is a critical step. A date picker gives you a local time. You must convert it to UTC before sending it to your server.

import { zonedTimeToUtc } from 'date-fns-tz';

function handleFormSubmit(formData) {
  // `formData.scheduledAt` might be "2025-01-01T10:00" from an 
  const localTimeFromPicker = formData.scheduledAt;
  const userTimeZone = "America/Chicago"; // Get this from the user's profile

  // Convert the local time to an absolute UTC Date object
  const utcDate = zonedTimeToUtc(localTimeFromPicker, userTimeZone);

  // Now, send `utcDate.toISOString()` to your API to save in the database
  saveEvent({ scheduledAt: utcDate.toISOString() });
}
Enter fullscreen mode Exit fullscreen mode

6. 🎯 Solving the 'Broken Date Filter' Nightmare

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, using a user in India as our example.

Example 1: Filtering for a Single Day

The Question: "How do I find all records created on July 19th for my user in India?"

The Wrong Way ❌

This common approach creates a filter for a UTC day, not the user's local day in India. This is a trap!

// This code will NOT match the user's local day correctly.
const selectedDay = "2025-07-19";

// PROBLEM: This is 12:00 AM UTC, not 12:00 AM India Standard Time.
const startOfDayUTC = new Date(selectedDay);
startOfDayUTC.setUTCHours(0, 0, 0, 0);

const endOfDayUTC = new Date(selectedDay);
endOfDayUTC.setUTCHours(23, 59, 59, 999);

const condition = and(
    gte(posts.createdAt, startOfDayUTC),
    lte(posts.createdAt, endOfDayUTC)
);
Enter fullscreen mode Exit fullscreen mode

Q: But that code looks so simple. What's the actual problem?

A: The problem is setUTCHours(). It defines a day based on UTC, but your user lives in IST (UTC+5:30).

  • Your user's day on July 19th starts at 12:00 AM IST.
  • In UTC, that same moment is 6:30 PM UTC on July 18th.
  • Your code starts the filter at 12:00 AM UTC on July 19th.
  • The result: Your filter starts 5.5 hours too late and will miss all records created in the first 5.5 hours of your user's day!
The Right Way ✅

We create a precise UTC range that perfectly matches the user's single local day, from their midnight to their next midnight.

// Correctly builds a UTC range for a single user day in India.

import { zonedTimeToUtc } from 'date-fns-tz';
import { addDays } from 'date-fns';
import { and, gte, lt } from 'drizzle-orm';

// --- Inside your query logic ---

const selectedDayStr = "2025-07-19";      // User selected a single day
const userTimeZone = "Asia/Kolkata";      // From the user's profile

// 1. Find the exact moment the user's day started, in UTC.
const startOfDayInUtc = zonedTimeToUtc(selectedDayStr, userTimeZone);

// 2. Find the exact moment the user's *next day* starts, in UTC.
const startOfNextDayInUtc = zonedTimeToUtc(
    addDays(new Date(selectedDayStr), 1),
    userTimeZone
);

// 3. Build the condition to find everything between these two moments.
const filterCondition = and(
    gte(posts.createdAt, startOfDayInUtc),
    lt(posts.createdAt, startOfNextDayInUtc)
);
Enter fullscreen mode Exit fullscreen mode

Q: Why do you find the start of the next day and use lt (less than)? Can't I just use lte and 23:59:59?

A: Great question! We do it this way for two critical reasons:

  1. Precision ✅: Databases like PostgreSQL can store time with microsecond precision. 23:59:59.999 is not the last possible moment of a day. A record saved at 23:59:59.999500 would be missed by your lte query. Using < start of next day is a perfect boundary that catches everything.
  2. Clarity ✅: This approach avoids "magic numbers" (23, 59, 999). The code clearly states its intent: "find everything that happened before the next day began." It's cleaner and less bug-prone.

Example 2: Filtering a Date Range

The Question: "How do I find all records between July 1st and July 31st for my user in India?"

The Wrong Way ❌

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.
const fromDateStr = "2025-07-01";
const toDateStr = "2025-07-31";

// PROBLEM #1: This filter starts 5.5 hours too late for a user in India.
const startRangeUTC = new Date(fromDateStr);
startRangeUTC.setUTCHours(0, 0, 0, 0);

// PROBLEM #2: This filter ends 5.5 hours too early for a user in India.
const endRangeUTC = new Date(toDateStr);
endRangeUTC.setUTCHours(23, 59, 59, 999);

const condition = and(
    gte(posts.createdAt, startRangeUTC),
    lte(posts.createdAt, endRangeUTC)
);
Enter fullscreen mode Exit fullscreen mode

Q: Wait, I'm still confused. Why is setUTCHours(0,0,0,0) wrong for the fromDate?

A: It's wrong because it ignores the user's time zone.

  • Your user wants to start filtering from July 1st at 12:00 AM IST.
  • Your code starts filtering from July 1st at 12:00 AM UTC.
  • These are different moments in time! Your code will miss the first 5.5 hours of data from the user's fromDate.
The Right Way ✅

The principle is identical to the single-day filter: we find the start of the user's local "from" day and the start of the day after their "to" day.

// Correctly builds a UTC range from the user's local date selection.

import { zonedTimeToUtc } from 'date-fns-tz';
import { addDays } from 'date-fns';
import { and, gte, lt } from 'drizzle-orm';

// --- Inside your query logic ---

const fromDateStr = "2025-07-01";         // User selected "From" date
const toDateStr = "2025-07-31";           // User selected "To" date
const userTimeZone = "Asia/Kolkata";      // From the user's profile

// 1. Get the exact moment the user's "from" day starts, in UTC.
const startRangeUtc = zonedTimeToUtc(fromDateStr, userTimeZone);

// 2. To include the *entire* "to" day, get the START of the *next* day.
const nextDayAfterToDate = addDays(new Date(toDateStr), 1);

// 3. Get the exact moment that next day starts, in UTC.
const endRangeUtc = zonedTimeToUtc(nextDayAfterToDate, userTimeZone);

// 4. Build the final, correct Drizzle condition.
const filterCondition = and(
    gte(posts.createdAt, startRangeUtc),
    lt(posts.createdAt, endRangeUtc)
);
Enter fullscreen mode Exit fullscreen mode

Q: I see, so the logic is the same for both single-day and date-range filters?

A: Exactly! The core principle never changes:

  1. Identify the start of the user's local day boundary.
  2. Identify the end of the user's local day boundary (which is the start of the next day).
  3. Convert both of those moments to UTC using zonedTimeToUtc.
  4. Use gte for the start and lt for the end.

This pattern will fix your date filter bugs permanently.


7. 📄 Special Cases: PDF Generation & Server Jobs

A common bug: a server in a UTC time zone generates a PDF showing UTC time, not the user's local time.

The Rule: Always pass the user's time zone to any server-side process that formats a date.

// Inside a server-side PDF generation service
import { formatDateTime12Hr } from '@/utils/date';

function generateInvoicePdf(invoiceData, userTimeZone: string) {
  // `invoiceData.createdAt` is the UTC timestamp from the database
  const displayTime = formatDateTime12Hr(invoiceData.createdAt, userTimeZone);
  // Now, use `displayTime` in your PDF template for a correct, localized date.
}
Enter fullscreen mode Exit fullscreen mode

8. ⚠️ Common Pitfalls and How to Avoid Them

Here are mistakes I've made so you don't have to:

  • Storing Local Times: Never save a timestamp without its time zone context. It becomes ambiguous and will cause bugs.
  • Ignoring Daylight Saving Time (DST): Never write your own logic for DST. It's a nightmare. A good library like date-fns-tz handles it for you.
  • Using Abbreviations like CST or PST: These can mean different things! Always use the full IANA names like America/Chicago.
  • Assuming Server and User are in the Same Zone: Test your app with different user time zones from day one. Set your local server to UTC to mimic production.

9. ✈️ A Journey Across Time: From Chicago to Kolkata

To truly understand this, let's follow a single moment in time across the world, using UTC as our anchor.

world map graphic with UTC as a central anchor, showing Chicago and Kolkata

The US to India Journey

  • A user in Chicago (America/Chicago, UTC-6) schedules an event for 10:00 AM.
  • Step 1 (To UTC): To find our anchor time, we add 6 hours. 10:00 AM + 6 hours = 4:00 PM UTC. This is what we store.
  • Step 2 (From UTC): A user in Kolkata (Asia/Kolkata, UTC+5:30) views it. We take our anchor time and add 5.5 hours. 4:00 PM UTC + 5.5 hours = 9:30 PM. They correctly see 9:30 PM.

The India to US Journey (and crossing a date boundary)

  • A user in Kolkata schedules something for 10:00 AM.
  • Step 1 (To UTC): 10:00 AM - 5.5 hours = 4:30 AM UTC.
  • Step 2 (From UTC): A user in Chicago views it. 4:30 AM UTC - 6 hours = 10:30 PM... on the previous day!

This is where the 24-hour timeline graphic makes everything clear.

24-hour timeline graphic illustrating the date change

Our journey from India left us anchored at 4:30 AM UTC. To find the time in Chicago, we need to subtract 6 hours. As the timeline visually shows, starting at 4:30 AM and moving backward 6 hours forces us to cross over the midnight (00:00) line.

This jump across midnight is why we don't just land at a different time—we land on a different day. The result is 10:30 PM on the previous day. This visual makes it obvious why time zone math can change not just the hour, but the date itself, and it's a core reason why storing local time is so dangerous. Without UTC as our reference, we could easily lose track of which day an event actually occurred.


10. 🤔 FAQ: Conversational Answers to Common Questions

Q: "Wouldn't it just be easier to store the user's local time?"
A: "It seems easier, but it's a trap! What happens when a user travels or when Daylight Saving Time kicks in? The data becomes ambiguous. Storing in UTC gives you one absolute truth you can always rely on."

Q: "Why date-fns-tz instead of the native JavaScript Intl API?"
A: "Intl is powerful, but its implementation can be inconsistent across browsers and environments. date-fns-tz provides a more predictable and unified experience, especially for complex conversions."

Q: "What about the new Temporal API I've heard about?"
A: "Temporal is the future! It will solve many of these problems natively. However, as of mid-2025, it's not yet fully supported everywhere. Until then, date-fns-tz is the most reliable, production-ready choice."


11. ✅ Conclusion: Mastering Time for a Global World

Time zone handling is complex, but by following these rules, you can build applications that handle time correctly and consistently. Remember my personal mantra: store universally (UTC), but display personally (user's local zone).

The investment in getting time right pays off massively in user satisfaction, data integrity, and developer sanity.

For the full code and a working example, you can check out the GitHub repository I made for this guide.

👉 GitHub Repo: utc-timezone-best-practices

This is the preferred guide structure that I follow with my team to avoid confusion. All criticism and feedback are welcome! I'm always looking to learn and improve. Let me know what you think.

Comments 2 total

  • Kok Wui Lai
    Kok Wui LaiJul 21, 2025

    why this 8 look suspiciously like 6 that trying to be 8?

    • Jackson Kasi
      Jackson KasiJul 21, 2025

      Hey! I'm impressed by your sharp observation 😂. The image was generated using AI, so those mistakes are expected.

Add comment