Back to Blog

SaaS Timezone Handling: Storage, Display, Scheduling

Published: June 29, 2026
SaaS Timezone Handling: Storage, Display, Scheduling

There's one timezone bug that every scheduling feature ships at least once: a user sets a recurring reminder for 9:00 AM, it works perfectly for months, and then the clocks change and it fires at 3:00 AM — waking up a few hundred enterprise users and generating a support queue by breakfast. SaaS timezone handling is full of traps like that, and almost all of them come from one mistake: treating a future wall-clock time as if it were a fixed instant.

Here's the rule that prevents most of the pain: store the past in UTC, store the future as wall-clock time plus an IANA timezone, and only convert at the edges. A payment that happened is an instant — UTC is perfect. A reminder that will happen at 9am local is a rule, not an instant, and freezing it to UTC breaks it the day Daylight Saving Time moves. Get that distinction right and the rest is mechanics.

SaaS timezone handling: the same moment is a different wall-clock time everywhere

SaaS Timezone Handling Rule 1: Past in UTC, Future in Wall-Clock

Split your timestamps into two categories and never blur them:

  • Things that happened — audit logs, invoices, events. Store UTC with TIMESTAMPTZ, which normalizes to UTC internally and indexes chronologically. This is the right type for an audit log and every other historical record.
  • Things that will happen on a schedule — a recurring "9am every Tuesday." Store the local time (09:00) and the IANA zone (Europe/London) in separate columns, and compute the UTC instant at run time.

If a country shifts its DST start by two weeks, a UTC timestamp you computed in advance is now simply wrong. The wall-clock-plus-zone version recalculates against the current rules and stays correct.

Rule 2: Capture the Zone as an IANA Identifier

You can't localize anything without knowing where the user is. Detect it automatically during onboarding:

JavaScript
1// e.g. "America/Los_Angeles"
2const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;

Save that to the user profile — but always expose a dropdown override for travelers and distributed teams. And use IANA Continent/City identifiers, never EST/GMT+5: abbreviations are ambiguous and don't carry DST rules, so they'll betray you exactly when the clocks change.

Rule 3: Display Without Hydration Crashes

In a server-rendered Next.js app, formatting a date to local time on the server and re-formatting it on the client throws a hydration mismatch. Render neutral on the server, format after mount:

TSX
1import { useEffect, useState } from 'react';
2import { formatInTimeZone } from 'date-fns-tz';
3
4export function LocalizedTime({ utc, tz }: { utc: string; tz: string }) {
5  const [mounted, setMounted] = useState(false);
6  useEffect(() => setMounted(true), []);
7
8  if (!mounted) return <span></span>; // identical on server and first client render
9
10  return <time dateTime={utc}>{formatInTimeZone(new Date(utc), tz, 'yyyy-MM-dd hh:mm a zzz')}</time>;
11}

Tie the user's zone into your broader internationalization setup so locale and timezone travel together.

A recurring 9am job stored as UTC fires at the wrong hour when DST shifts

Rule 4: Survive the DST Gaps and Repeats

Twice a year the calendar misbehaves. Spring forward and 2:30 AM doesn't exist — the clock jumps 1:59 → 3:00. Fall back and 1:30 AM happens twice. Your scheduler needs an explicit policy:

  • Skipped hour (spring forward): if a job lands in the missing hour, push it forward 60 minutes so it still runs.
  • Repeated hour (fall back): if a job lands in the doubled hour, fire on the first occurrence only, so it doesn't run twice.

Don't leave this to chance — pick the policy and encode it, because "it ran twice and double-charged everyone" is a bad way to discover DST.

Rule 5: Build a Timezone-Aware Scheduler

System cron runs on one clock (usually UTC), which can't honor each tenant's local time. Run an orchestrator on a short interval that looks ahead and fires tenant jobs when their local moment arrives:

SQL
1-- Conceptual: find tasks whose next local run time has arrived.
2-- In practice, compute next_run_utc when you save/last-run the task,
3-- storing local_time + IANA zone, then simply:
4SELECT id, event_name
5FROM scheduled_tasks
6WHERE next_run_utc <= NOW();

The reliable pattern is to compute and store next_run_utc whenever a task is created or completes — derived from its wall-clock time and zone against the current DST rules — then poll for due rows. A job set for 8am Asia/Tokyo fires correctly even though your servers sit in a European data center. This rides on the same worker infrastructure as your background job queue.

Rule 6: Report on the User's Day, Not UTC's

"October 1st" is a different window in New York than in Paris. Query raw UTC days and every user gets a slightly wrong total. Shift inside the aggregation:

SQL
1SELECT
2  DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :user_tz) AS local_day,
3  COUNT(*) AS sales
4FROM orders
5WHERE tenant_id = :tenant_id
6GROUP BY local_day
7ORDER BY local_day;

Convert the requested local range into a UTC range before the scan, then bucket by the user's local day. The numbers finally match what the user sees on their own calendar.

Reports must bucket by the user's local day, not UTC's, or the totals quietly disagree

Rule 7: Test Across Zones (and Watch Temporal)

A bug that only appears in Pacific/Auckland won't show up on your laptop in one timezone. Force the runtime zone in tests:

JavaScript
1describe('scheduler across DST', () => {
2  beforeAll(() => { process.env.TZ = 'America/Los_Angeles'; });
3  it('handles the spring-forward gap', () => {
4    expect(nextRun('2026-03-08T02:00:00', 'America/Los_Angeles')).toBeDefined();
5  });
6});

For libraries, Moment.js is retired (mutable, heavy) — reach for the built-in Intl API for display, date-fns or Luxon for arithmetic. And keep an eye on the Temporal API: it's the TC39 standard finally landing in browsers, with first-class ZonedDateTime and DST-aware math built in. It's the thing that eventually makes most of this post a one-liner — but until it's everywhere, the rules above are how you stay correct.

ToolFootprintUse for
Intl APIBuilt-inDisplay formatting, zero deps
date-fns / date-fns-tzTree-shakableFunctional date math in React/Next.js
LuxonHeavierDeep timezone parsing on the backend
TemporalEmerging standardFuture default; DST-aware by design

Store the past as an instant and the future as a rule, detect the zone properly, and shift only at the edges — display and reporting. Do that and the clocks can spring forward all they like; your 9am reminder stays at 9am, and nobody gets a product announcement at 3 in the morning.

Frequently Asked Questions

For things that already happened — audit logs, payments, clicks — yes, store UTC with PostgreSQL's TIMESTAMPTZ. But for future recurring events, no. A recurring '9am every Tuesday' stored as a fixed UTC timestamp breaks the moment Daylight Saving Time shifts, because the correct UTC instant for 9am local changes. Store the wall-clock time plus the IANA timezone instead, and compute the UTC instant at run time.

Because a UTC timestamp freezes a wall-clock time that's supposed to move. If a government changes its DST rules — and they do — a future event you converted to UTC months ago will fire at the wrong local time. Storing '09:00' + 'Europe/London' means you always recompute against the current rules, so the event stays at 9am local no matter what the timezone authorities change.

Use Intl.DateTimeFormat().resolvedOptions().timeZone in the browser to get the IANA identifier (like America/Los_Angeles) automatically during onboarding. Save it to the user profile, but also expose a dropdown so travelers and remote teams can override it. Never use three-letter abbreviations like EST or PST — they're ambiguous and don't carry DST rules.

Render dates in UTC (or a neutral placeholder) on the server, then format to the user's timezone after the component mounts on the client. If the server renders local time and the client re-renders a different local time, React throws a hydration mismatch. A mounted flag with useEffect, or a client-only timestamp component, keeps the two renders identical.

Convert the user's requested local day range into a UTC range before querying, or bucket in SQL with created_at AT TIME ZONE 'UTC' AT TIME ZONE user_tz. 'October 1st' is a different window in New York than in Paris, so querying raw UTC days gives each user a slightly wrong total. Do the timezone shift inside the aggregation so the buckets line up with what the user means by a day.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio