A customer in Sydney picks June 5, the code turns local midnight into UTC, and the record says June 4. A time zone handling checklist prevents that off-by-one-day bug with five rules: store instants in UTC, store calendar dates as dates, keep each user’s IANA zone, convert only at display, and test the boundaries.

The time zone handling checklist: five rules and what each one prevents

A time zone handling checklist for a web app has five rules: store every event timestamp as a UTC instant, store calendar dates as dates with no time, keep an IANA zone id for each user, convert only at the API and display edges, and run servers, jobs and tests in UTC with boundary tests on every change.

The Sydney case above illustrates the mechanism; it is not a case from a real app. Sydney runs on UTC plus ten hours in June, so local midnight on June 5 is 14:00 UTC on June 4, and code that keeps only the UTC date files the booking a day early. Time is one control among several that keep stored data correct; the others are in the data consistency checklist for SaaS.

Read across, the table below is how to handle time zones in an application: each rule names the failure it stops and the layer that enforces it. The last column is my reading of where that layer sits.

RuleWhat it preventsWhere it is enforced
1. Store every event timestamp (created, paid, sent, logged in) as a UTC instant in a zone-aware columnTimes that shift when the server or session zone changesColumn type: timestamptz
2. Store every calendar date (a birthday, a due date, a booking day, a billing anchor day) as a date with no time, never as a midnightThe date that moves by a day for users far from UTCColumn type: date; API as YYYY-MM-DD
3. Keep an IANA zone id per user or account, such as America/Los_Angeles, never a fixed offset and never an abbreviation such as ESTLocal times that go wrong after a daylight saving changeA zone column on the profile or account
4. Convert only at the edges: the API sends timestamps with Z or a numeric offset, the UI formats in the viewer’s zoneDate math on strings in the middle of the codeAPI contract and UI
5. Run servers, the database session, CI and scheduled jobs in UTC, with boundary tests on every changeCode that is right on a laptop and wrong in productionConfig and CI

Rule 3 is about the zone, not the offset. Zone ids come from the IANA Time Zone Database, often called tz or zoneinfo, which “contains code and data that represent the history of local time for many representative locations worldwide”. In the W3C note’s description, those ids “usually consist of a region and exemplar city”. A fixed offset of +10:00 is right for Sydney in June and wrong in January, when Sydney is on daylight saving time. An abbreviation such as EST is worse still: my reading is that it names no rule set at all.

A future event needs the zone even more. The W3C note says an application with future events needs “the time zone (and not merely the local time offset)”, because “a future event’s wall time depends on time zone-related information, such as summer time transitions.” My working rule for a future local event, such as a nine o’clock appointment next March, is to store the local time plus the zone id and compute the instant only when it is needed.

Rule 4 puts conversion at two edges. The API writes timestamps in the RFC 3339 form, where time-offset is "Z" / time-numoffset, a Z or a signed hours-and-minutes offset. The UI formats for whoever is looking. Nothing in between parses, slices or adds to date strings. Rule 5 keeps every machine on one clock, so a job, a log line and a test agree, and object keys or lifecycle rules that embed a date use the UTC date (the storage side is a separate question: the object storage security checklist).

What is UTC versus local time storage

UTC versus local time storage comes down to three kinds of value. An instant, such as a payment, is stored in UTC. A calendar date, such as a due date, is stored as a date. A future local time, such as a class at 18:00 in Lisbon, is stored, by my working rule, as local time plus zone id.

The vocabulary comes from the W3C’s note on working with time and time zones. An instant is what the note calls incremental time, counted in units “measured from a specific moment in time (which is called the epoch)”. A calendar date is a floating time, “a date or time value in a calendar that is not a specific instant in time”, used when a value’s “local wall time expression needs to stay constant, regardless of the viewing user’s time zone”; a birthdate is its example. The note also names the bug this page is about: “Errors are often the result when a floating time value is deserialized into” an incremental time type, which, in its words, “ties the value to UTC”.

The difference between UTC and local time is that UTC is one clock for everyone, while local time is what a wall clock shows in one zone on one date, with that date’s daylight saving rule applied.

PostgreSQL’s date and time types show why the column type matters. A timestamp with time zone column (timestamptz) converts input that carries a zone to UTC and takes input without one in the TimeZone parameter’s zone; either way, “the value is stored internally as UTC, and the originally stated or assumed time zone is not retained”. A timestamp without time zone column is the trap: Postgres “will silently ignore any time zone indication” in the input, and a column declared as plain timestamp is this type. It is the first column type to look for when stored times disagree with each other.

Kind of valueExampleWhat to storePostgres typeThe mistake
Instanta payment, a login, a sent emailthe moment, in UTCtimestamptztimestamp without time zone, which ignores the offset it was given
Calendar datea birthday, an invoice’s due date, a delivery daythe date only, YYYY-MM-DDdatea UTC midnight, which lands on another day for users far from UTC
Future local timea class at 18:00 in Lisbon next springthe local date and time plus the zone idtimestamp without time zone, plus a text column holding Europe/Lisbona UTC instant computed today, which is wrong if the zone’s rules change before the date

What goes wrong without it: dates off by one day for some users

A date that shows one day off for only some users comes from how JavaScript reads a date: a date-only string such as 2026-06-05 is read as midnight UTC, and a local midnight becomes the previous UTC day for users east of Greenwich. The bug follows each user’s offset, which is why the developer’s own zone hides it.

MDN’s Date reference states the rule: “When the time zone offset is absent, date-only forms are interpreted as a UTC time and date-time forms are interpreted as a local time.” It calls the UTC half “a historical spec error” that could not be changed because of web compatibility. So new Date("2026-06-05") is midnight UTC and prints as June 4 anywhere west of Greenwich, while a date picker’s local midnight turns into the previous day’s UTC date anywhere east of it, which is the Sydney case.

The parse rule is one failure of six. Each has a symptom a customer can describe:

What the user reportsThe causeThe rule that prevents it
A due date or birthday shows the day beforea date-only value parsed as UTC midnight, or a date stored as a midnightRule 2
The daily report does not match the tilltotals grouped by the UTC day while the business closes its day in ChicagoRule 1, grouped by the account’s zone (Postgres section below)
A trial or renewal ends hours early or latethe end time computed in the wrong zoneRules 1 and 3
A reminder arrives twice, or not at alla job scheduled in local time across a daylight saving changeRule 5
A 2:30 a.m. booking vanishes in spring, or a 1:30 a.m. one is ambiguous in autumna local time that does not exist, or one that happens twiceRule 3, plus a written rule for gaps and overlaps
Right on the developer’s laptop, wrong in productionthe host runs in UTC and the laptop does notRule 5 and the tests below

The trial row belongs to another page: renewal dates, the billing clock and Stripe’s own timestamps belong to a separate question, what is the subscription lifecycle in Stripe, and this page gives only the date rule. The reminder row gets one clause here, because the zone a schedule runs in is set where its cron schedules are defined.

The two daylight saving rows follow the current US rule. According to NIST on daylight saving time, it “begins at 2:00 a.m. on the second Sunday of March”, when the clock “skips ahead to 3 a.m.”, and “ends at 2:00 a.m. on the first Sunday of November”, when “that hour is repeated”. During 2026 it runs from March 8 to November 1. A local time inside the skipped hour does not exist, and one inside the repeated hour names two instants.

Inconsistent time handling can distort billing schedules, reporting, and user expectations, and the table has a row for each of the three.

How to do it on the common stacks

The fixes sit in four places: the database, JavaScript, the framework and the date library.

Postgres and Supabase: timestamptz, date, and reports by the business’s day

Use timestamptz for instants and date for calendar dates. Check the session zone with SHOW TimeZone; and keep it UTC. Supabase’s note on database time zones says: “Every Supabase database is set to UTC timezone by default. We strongly recommend keeping it this way, even if your users are in a different location.” It shows the form alter database postgres set timezone to 'America/New_York'; for the rare case the default has to move.

To find existing damage, list every column stored without a zone. PostgreSQL’s docs for information_schema.columns say “Only those columns are shown that the current user has access to (by way of being the owner or having some privilege)”, so run the query as the role that owns your tables, or a restricted role will miss some.

-- Columns that store a time with no zone
SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE data_type = 'timestamp without time zone'
  AND table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY 1, 2, 3;

Converting one of those columns is a migration with one written assumption, the zone the old values were written in, rehearsed on a copy of the database before it touches production.

Reports group by the account’s day, not the server’s. The AT TIME ZONE operator “converts time stamp without time zone to/from time stamp with time zone”; applied to a timestamptz value, it gives the time “as the time would appear in that zone”, so truncating that result to a day gives the business’s day.

SELECT date_trunc('day', created_at AT TIME ZONE 'America/Chicago') AS business_day,
       count(*) AS orders
FROM orders
WHERE created_at >= timestamp '2026-06-01' AT TIME ZONE 'America/Chicago'
  AND created_at <  timestamp '2026-07-01' AT TIME ZONE 'America/Chicago'
GROUP BY 1
ORDER BY 1;

Conversions inside a query have a cost of their own. One app I audited, a point-of-sale app, had analytics queries that cast the timestamp column, which made the index built for that column unusable. My reading of that: a report grouped by the business’s day wraps the column in a conversion too, so filter on a range of the raw column, as the WHERE clause above does, and convert only in the grouping, or index the expression you group on. Indexes and query plans are a subject of their own.

JavaScript and TypeScript: Date, Intl and Temporal

A JavaScript Date holds one number, the milliseconds since the epoch, and “The local timezone is not stored in the date object, but is determined by the host environment (user’s device).” Two traps follow. The first is the parse rule in the table above. The second is toISOString(), which always writes the offset as Z: call it on a local midnight in Sydney and the string carries the previous day.

Four habits avoid both. Send and receive timestamps as ISO strings ending in Z. Keep calendar dates as YYYY-MM-DD strings from the form to the database, and never pass them through Date. Format for display with Intl.DateTimeFormat and an explicit timeZone; MDN’s Intl.DateTimeFormat reference says the option “Can be any IANA time zone name” and that without it the default is “the runtime’s time zone”. Read the viewer’s zone once with resolvedOptions().timeZone, which MDN documents as the way “to get the user’s current time zone”, save it to the profile, and give the user a setting to change it. Server-rendered pages format in the zone saved on the profile, never the server’s.

Converting a UTC time to the viewer’s own time is then one formatter call:

const paidAt = new Date("2026-06-04T14:00:00Z"); // an instant from the API
const zone = new Intl.DateTimeFormat().resolvedOptions().timeZone; // saved to the profile
const shown = new Intl.DateTimeFormat("en-US", {
  year: "numeric", month: "short", day: "numeric",
  hour: "numeric", minute: "2-digit",
  timeZone: zone, timeZoneName: "short",
}).format(paidAt); // a viewer in Australia/Sydney sees June 5
const dueDate = "2026-06-05"; // a calendar date stays a string

Temporal is the longer-term fix. The TC39 repository for Temporal says it “is currently Stage 4”, and MDN’s Temporal reference still marks it “Limited availability” because “it does not work in some of the most widely-used browsers”. The same README lists polyfills to use meanwhile, with temporal-polyfill marked “Stable release available”, and warns against using the repository’s own. A calendar date in Temporal is a Temporal.PlainDate, and the W3C note says “JavaScript Temporal calls these values plain dates or times.”

Time zone Rails settings, and the Django equivalent

Time zone handling in Rails rests on two settings and one habit: Active Record reads times from the database as UTC by default, config.time_zone sets the app’s zone, and code calls Time.current, not Time.now. Per-user zones wrap each request in Time.use_zone with the zone saved on the account.

The Rails configuring guide says config.time_zone “Sets the default time zone for the application and enables time zone awareness for Active Record”, and that setting defaults to “UTC”. For config.active_record.default_timezone, which picks Time.local or Time.utc when pulling times from the database, “The default is :utc.” A Ruby on Rails timezone setup that keeps both defaults therefore reads UTC from the database and works in UTC until you set a zone. The Rails API describes Time.current this way: it “Returns Time.zone.now when Time.zone or config.time_zone are set, otherwise just returns Time.now.”

For per-user zones, Time.use_zone “Allows override of Time.zone locally inside supplied block”, and the API’s own example wraps each request in an around_action that calls Time.use_zone(current_user.timezone) { yield }. ActiveSupport::TimeZone is a wrapper around TZInfo that shows friendlier names (“Eastern Time (US & Canada)” instead of “America/New_York”) and limits the list to “a meaningful subset of 134 zones”. My working rule for Rails time zones: store the IANA id on the account and map it to the friendly name only for display, so the stored value never depends on the shorter list.

Django needs less setup. With time zone support enabled, Django “stores datetime information in UTC in the database”, and “Time zone support is enabled by default.” You set the current zone to the user’s with activate(), as Django’s time zone docs recommend.

The Moment library and the Moment.js alternatives

The Moment library is “a legacy project” in its maintainers’ words, kept in maintenance mode with its mutable API as it is. The alternatives are the platform’s Intl and Date, the Temporal API, Luxon, and date-fns or Day.js with their time zone add-ons; the test for each is whether it takes an IANA zone id.

Moment’s project status notes carry an update dated August 17, 2026 that supersedes the 2020 note. It says “Moment is a legacy project, and stability for existing users matters more than expanding its scope”, and that the maintainers are “not accepting new user-facing features or capabilities” and “not redesigning Moment’s mutable API”. The most significant change, in the update’s words, is “the arrival of AI-assisted and agentic software development.” Moment “appears throughout the code, documentation, tutorials, and questions on which coding agents learned”, and agents now “select Moment, add it to projects, write code with it, and encounter it in existing dependency trees, often without a person making a deliberate library choice.” The same update says maintenance mode “no longer categorically rules out Moment 3.0”.

Whether Moment.js is obsolete for your app is a narrower question. The 2020 note called it “not dead, but it is indeed done” and said “In most cases, you should not choose Moment for new projects”; the 2026 update says “The spirit of our 2020 message has not changed”. My working rule for an app an AI tool wrote: check package.json for moment and moment-timezone early, since an agent may have added them without anyone choosing.

What to replace Moment.js with depends on how much zone math the app does. The alternatives below are described in each project’s own words, from sources such as Luxon’s API docs, date-fns’ time zone package and Day.js’s timezone plugin.

OptionHow it handles time zonesImmutable or mutableStatus in the project’s own words
Intl and Date, built inIntl.DateTimeFormat takes any IANA name as timeZone; a Date stores no zoneDate is mutable: setDate() “Sets the day of the month for a specified date”MDN: “Baseline Widely available”
Temporal”built-in time zone and calendar representation” (MDN)“All Temporal objects are immutable.""currently Stage 4”; MDN: “Limited availability”
Luxon”Native time zone and Intl support (no locale or tz files)”; a zone can be “any IANA zone supported by the host environment""Immutable, chainable, unambiguous API”not stated in Luxon’s docs
date-fns with @date-fns/tzTZDate and TZDateMini “perform all calculations in the given time zone”; they accept IANA names”always returns a new date instance""date-fns v4.0 with first-class time zone support is out”
Day.js with its timezone pluginthe plugin adds dayjs.tz “to parse or display between time zones” and takes names such as America/New_York”All API operations that change the Day.js object will return a new instance instead.”not stated in Day.js’s docs
Moment, for comparisonMoment Timezone, whose “data updates will continue to follow IANA time zone database releases""Moment objects are mutable.”legacy, in maintenance mode (2026 update)

No row wins outright. My test for each is two questions: does it take an IANA zone id, and are its values immutable? My working rule on replacing Moment: it is worth the effort when the app does zone math with it (schedules, reminders, reports by the account’s day), rarely for a few dates formatted on a settings page, and the swap happens behind the boundary tests below, never before them.

How to verify it: test date boundaries across time zones

Date boundaries across time zones are tested by running the suite under several process zones with a fixed clock: UTC, one west, one far east and one half-hour zone. The cases are a date round trip, month end, the spring gap, the autumn overlap, a scheduled job, month arithmetic and two viewers.

Deliverable 4.8 of the Production Hardening Sprint is verified this way: “Test timestamps across time zones and relevant date boundaries.”

Set the process zone with the TZ environment variable, as in TZ=Pacific/Auckland npm test; Node.js supports “basic timezone IDs (such as ‘Etc/UTC’, ‘Europe/Paris’, or ‘America/New_York’)”. My working rule is four zones: Etc/UTC, one west of Greenwich (America/Los_Angeles), one far east (Pacific/Auckland) and a half-hour zone (Asia/Kolkata; the W3C note’s table marks India as a zone that “observes a 30 minute offset”). Pin the clock too, so a test written in March cannot pass by accident in July; Playwright’s clock has setFixedTime, which “Sets the fixed time for Date.now() and new Date()”. In browser tests, Playwright’s emulation docs set the page’s zone with timezoneId and note that this “only affects the browser timezone and locale, not the test runner timezone”, so set TZ as well.

  1. 01 A user in Pacific/Auckland picks a date in a form, saves it, and reads back the same date on every screen and in every export.
  2. 02 An event created at 23:30 local time on the last day of a month appears in that month in the user's report, not the next one.
  3. 03 The spring gap: a booking at 2:30 a.m. in America/New_York on the second Sunday of March (March 8 in 2026) is rejected or moved by a rule written down in advance.
  4. 04 The autumn overlap: 1:30 a.m. in New York on the first Sunday of November (November 1 in 2026) happens twice, and the app stores the instant the user meant.
  5. 05 A daily job scheduled across a daylight saving change runs exactly once on the day of the change.
  6. 06 Month arithmetic: one month after January 31 lands where the written rule says, and a billing date matches what the payment provider shows.
  7. 07 Two users in different zones open the same record and each sees their own local time, with the zone labeled.

Rows with timestamps on these boundaries belong in the staging seed, so every developer runs into them; building one is a separate task: how to generate realistic fake data for staging. Record each run in a table like this:

TestZones runExpectedActualDate
1. Date round tripall fourthe same date on every screen and export
2. Month end at 23:30all fourthe event in its own month
3. Spring gapAmerica/New_Yorkrejected or moved by the written rule
4. Autumn overlapAmerica/New_Yorkthe intended instant stored
5. Job across a changeAmerica/New_Yorkone run
6. Month arithmeticall fourthe written rule; billing matches the provider
7. Two viewerstwo zoneseach sees local time with the zone labeled

My working rule is to keep the filled table with its dates, plus a short written statement of how the app treats local dates and display times (which zone a due date closes in, which zone a report’s day follows, what happens to a time inside the spring gap), as the record the next developer, or the next AI tool, reads before touching dates.

Where the sprint does this

In the Production Hardening Sprint, deliverable 4.8, Consistent time handling, is where we “Standardize event timestamps in UTC and document local-date and display-time behavior.” Deliverable 13.1, the production readiness report, is verified against one line: “Account for all 123 IDs; keep failures visible until resolved and explain genuine non-applicable items.” Building new product features or modules is outside the sprint, and your app’s current framework and hosting setup are our starting point. Every deliverable is listed in the published scope.

Common questions about time zones in a web app

Is DayJS better than Moment?

For new code, Day.js has a design Moment lacks: every change returns a new object, and Moment’s maintainers say they are keeping Moment’s API mutable. Moment’s own 2020 note, quoted in the Moment section above, advises against it for new projects in most cases. Day.js needs its utc and timezone plugins before it handles zones, so whether it is better for a given app depends, in my reading, on whether that app does zone math at all.

Why doesn’t everyone use UTC time?

Because people live by local clocks: a nine o’clock meeting and a due date are local ideas, and turning them into UTC changes what they mean. Software stores UTC for instants, keeps calendar dates as local wall-clock values, which the W3C note calls floating time, and keeps a future local event as its local time plus the zone.

What is the proper way to write time zones?

For machines, write a timestamp in the RFC 3339 form, ending in Z or a numeric offset such as +10:00, and name the zone with an IANA id such as America/New_York. For people, write the time with the zone named, never a bare abbreviation such as EST; that part is my reading, because an abbreviation does not say which daylight saving rules apply.

Does a by date include that day?

In an app it has to be decided and written down. My working rule: a due date stored as a date means the end of that day in the account’s zone, and the written statement of local-date behavior from the test section says so.