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
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 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 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 secondsdifferenceInSeconds(a, b)— measures elapsed time between two instantssubMinutes(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 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's05:00 UTC. In PST that's08:00 UTC. In UTC that's00: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 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| 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 |
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.
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).
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.
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 | 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 |
- 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
preDaylightSavingTimeflag appears in your code, you're doing it wrong — use Luxon and let it handle DST
- 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
- Use Jest fake timers to avoid flakes around UTC midnight and DST transitions
- Avoid setup in
describeblocks — fake timers only apply inside test cases (beforeAll/it) - Test across multiple timezones (e.g., verify schedules set up in Central work in Eastern)
- Always use the location timezone consistently, not the browser timezone
- date-fns
startOfDaygives 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"