Skip to content

Instantly share code, notes, and snippets.

@sumeet-bansal
Last active March 6, 2026 20:23
Show Gist options
  • Select an option

  • Save sumeet-bansal/79d1ac5328b8073a6ea4f1f424ee1919 to your computer and use it in GitHub Desktop.

Select an option

Save sumeet-bansal/79d1ac5328b8073a6ea4f1f424ee1919 to your computer and use it in GitHub Desktop.
Why we prefer Luxon over date-fns for date math

Timezone Manifesto

Why We Prefer Luxon Over date-fns for Date Math

"The clocks of the world do not agree; they only pretend to."

Jorge Luis Borges, Labyrinths

TL;DR

Use Luxon for all date/time operations on the backend. date-fns is acceptable on the frontend where timezone math is rare. The core issue: date-fns operates on JS Date objects that default to the local timezone, which causes subtle bugs around DST transitions and cross-timezone operations.

The Key Concept: Absolute vs Relative Time Operations

The question to ask: does this operation's result change depending on what timezone you're in? If yes, it's absolute and you need Luxon. If it's just arithmetic on seconds, date-fns is fine.

Relative operations (timezone-agnostic)

Relative operations work with raw durations — fixed amounts of elapsed time. They don't need to know what timezone you're in because they're just adding/subtracting seconds from a Unix timestamp.

  • addHours(date, 2) — always adds exactly 7200 seconds
  • differenceInSeconds(a, b) — measures elapsed time between two instants
  • subMinutes(date, 30) — always subtracts exactly 1800 seconds

These are safe in date-fns. 7200 seconds is 7200 seconds regardless of whether you're in New York or Phoenix.

Absolute operations (timezone-dependent)

Absolute operations refer to a specific position on a calendar. They need a timezone to answer the question because "midnight" or "start of week" are different instants depending on where you are.

  • startOfDay(date) — midnight... but midnight where? In EST that's 05:00 UTC. In PST that's 08:00 UTC. In UTC that's 00:00 UTC. date-fns silently picks whatever timezone the runtime is in.
  • startOfWeek, endOfMonth, startOfHour — same problem.
// date-fns: silently uses local timezone
startOfDay(someDate) // midnight in... EST? UTC? depends on the machine

// Luxon: explicit about which timezone
DateTime.fromJSDate(someDate).setZone('America/New_York').startOf('day')

addDays is the sneaky one

addDays looks relative but it's actually absolute. "Add 1 day" in calendar terms means "same clock time tomorrow" — and date-fns does this by adjusting the day component while preserving local time. But if DST happens between today and tomorrow, "same clock time tomorrow" is 23 or 25 hours away, not 24.

We hit a real bug where addDays(1) added 25 hours because DST moved the clock back.

// DST spring-forward example (March):
// 2am jumps to 3am, so "1 day" is only 23 hours
addDays(marchSaturdayNoon, 1) // actually adds 23 hours, not 24

// Luxon handles this correctly:
DateTime.fromJSDate(marchSaturdayNoon)
  .setZone('America/New_York')
  .plus({ days: 1 }) // gives you Sunday noon, accounting for DST

Quick reference

Operation Type Safe in date-fns?
addHours, addMinutes, addSeconds Relative Yes
differenceInSeconds, differenceInMinutes Relative Yes
startOfDay, startOfWeek, endOfMonth Absolute No
addDays, addWeeks, addMonths, addYears Absolute No
format (with timezone display) Absolute No

Real Incidents at Traba

Phoenix Timezone Bugs

Multiple incidents where Phoenix (Arizona) shifts showed wrong times, off by 1 hour. Arizona doesn't observe DST, so when the rest of the US springs forward/falls back, naive timezone math breaks for AZ locations. Workers were seeing 6am when shifts were scheduled for 5am.

Root cause: Schedules were using the browser timezone while shift requests used the location timezone, causing discrepancies during DST transitions.

Integration Test Failures Around DST

Tests that passed all year would break in the weeks leading up to DST (March/November). A deployment failed because a test for Phoenix/Mountain Time schedules expected "07:00" but got "06:00" — the DST boundary silently shifted the result.

The backend relies on UTC timestamps but doesn't run in the UTC timezone locally, so any startOfDay-style operation produced different results on dev machines (EST) vs CI runners (UTC).

Twilio Queue Delayed Messages

Workers received notifications saying "your shift starts in 2 hours" — but the message was delayed in the Twilio queue, making it completely misleading. A worker got "shift starts at 12 PM" when it was actually 10 AM.

Lesson: Always use absolute times ("your shift starts at 10 AM") instead of relative times ("your shift starts in 2 hours") in communications. If a message is delayed, absolute times remain correct; relative times become misinformation.

rrule Performance Issues

Inefficient timezone operations in our rrule (recurring schedule) implementation were consuming ~20% of CPU. We were casting UTC to local time, calling rrule, then casting back — all because rrule used Luxon under the hood and we were trying to avoid it. The workaround was worse than just using Luxon directly.

Luxon vs date-fns Comparison

Luxon date-fns
Timezone handling First-class, explicit. Forces you to specify which timezone. Defaults to local timezone. Requires date-fns-tz addon for timezone awareness.
DST safety Automatically handles DST transitions via IANA timezone database. Silent footguns around DST. addDays can add 23 or 25 hours.
Immutability DateTime objects are immutable. Wraps mutable Date objects.
API style Chained method calls (dt.plus({ days: 1 }).startOf('day')) Composed function calls (startOfDay(addDays(date, 1)))
Interop Own DateTime type — requires conversion to/from Date Works directly with Date objects
Durations Rich Duration type for representing time spans No first-class duration support
Verbosity Slightly more verbose for simple operations More concise for relative operations
Performance Slightly heavier Lighter, tree-shakeable

Guidelines

Backend (Node server)

  • Use Luxon for all date math involving timezones, DST, or "absolute" operations
  • date-fns is tolerable for simple relative operations (differenceInSeconds, addHours) where timezone doesn't matter, but prefer Luxon for consistency
  • Never use startOfDay, startOfWeek, endOfDay, etc. from date-fns — these are absolute operations that silently depend on local timezone
  • If a preDaylightSavingTime flag appears in your code, you're doing it wrong — use Luxon and let it handle DST

Frontend

  • date-fns is fine since frontends generally don't need cross-timezone math
  • If doing any timezone conversion (e.g., displaying a shift time in the shift's location timezone rather than the user's browser timezone), use Luxon

Integration Tests

  • Use Jest fake timers to avoid flakes around UTC midnight and DST transitions
  • Avoid setup in describe blocks — fake timers only apply inside test cases (beforeAll/it)
  • Test across multiple timezones (e.g., verify schedules set up in Central work in Eastern)

General

  • Always use the location timezone consistently, not the browser timezone
  • date-fns startOfDay gives you midnight in whatever timezone the server runs in — this is almost never what you want
  • When in doubt: "just use Luxon and don't think about it"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment