TL;DR
date-fns-tz extends date-fns with timezone support — converts between timezones, formats with timezone offsets, works with native Date objects, tree-shakeable. Luxon is the modern date/time library by the Moment.js team — immutable, built-in timezone support via Intl API, Duration/Interval types, the successor to Moment.js. Spacetime is the lightweight timezone library — timezone-aware date objects, DST handling, human-friendly API, small bundle. In 2026: date-fns-tz for date-fns users, Luxon for comprehensive date/time with timezones, Spacetime for lightweight timezone operations.
Key Takeaways
- date-fns-tz: ~5M weekly downloads — date-fns extension, native Date, tree-shakeable
- Luxon: ~10M weekly downloads — Moment.js successor, immutable, Intl-based timezones
- Spacetime: ~500K weekly downloads — lightweight, timezone-first, human-friendly API
- date-fns-tz extends an existing library; Luxon and Spacetime are standalone
- Luxon has the most comprehensive API (Duration, Interval, timezone-aware)
- Spacetime has the smallest learning curve for timezone work
Quick Comparison
| date-fns-tz | Luxon | Spacetime | |
|---|---|---|---|
| Weekly Downloads | ~5M | ~10M | ~500K |
| Bundle Size (gzipped) | ~6 KB | ~22 KB | ~40 KB |
| TypeScript | ✅ | ✅ | ✅ |
| License | MIT | MIT | MIT |
| Approach | date-fns extension | Standalone immutable | Standalone mutable |
| IANA Timezone DB | Via date-fns-tz | Via Intl API | Bundled |
| DST Handling | ✅ | ✅ | ✅ |
| Tree-shakeable | ✅ | ❌ | ❌ |
date-fns-tz
date-fns-tz — timezone support for date-fns:
Convert between timezones
import { formatInTimeZone, toZonedTime, fromZonedTime } from "date-fns-tz"
const utcDate = new Date("2026-03-09T12:00:00Z")
// Format in a specific timezone:
formatInTimeZone(utcDate, "America/New_York", "yyyy-MM-dd HH:mm:ss zzz")
// → "2026-03-09 07:00:00 EST"
formatInTimeZone(utcDate, "Asia/Tokyo", "yyyy-MM-dd HH:mm:ss zzz")
// → "2026-03-09 21:00:00 JST"
formatInTimeZone(utcDate, "Europe/London", "yyyy-MM-dd HH:mm:ss zzz")
// → "2026-03-09 12:00:00 GMT"
// Convert UTC to zoned time (for display):
const nyTime = toZonedTime(utcDate, "America/New_York")
// → Date representing 7:00 AM (local equivalent)
// Convert zoned time back to UTC (for storage):
const backToUtc = fromZonedTime(new Date(2026, 2, 9, 7, 0), "America/New_York")
// → Date representing 12:00 PM UTC
Format with timezone info
import { formatInTimeZone } from "date-fns-tz"
const date = new Date("2026-03-09T12:00:00Z")
// Various format tokens:
formatInTimeZone(date, "America/New_York", "h:mm a z")
// → "7:00 AM EST"
formatInTimeZone(date, "America/New_York", "HH:mm:ss OOOO")
// → "07:00:00 GMT-05:00"
formatInTimeZone(date, "America/New_York", "PPP 'at' p zzz")
// → "March 9th, 2026 at 7:00 AM Eastern Standard Time"
With date-fns functions
import { addHours, isBefore, differenceInHours } from "date-fns"
import { formatInTimeZone, toZonedTime } from "date-fns-tz"
const meeting = new Date("2026-03-09T15:00:00Z")
// Standard date-fns operations work:
const reminder = addHours(meeting, -1)
const diff = differenceInHours(meeting, new Date())
// Then format in any timezone:
formatInTimeZone(meeting, "America/Los_Angeles", "h:mm a z")
// → "7:00 AM PST"
formatInTimeZone(meeting, "Europe/Berlin", "H:mm z")
// → "16:00 CET"
Luxon
Luxon — modern date/time library:
Timezone support
import { DateTime } from "luxon"
// Create in a timezone:
const ny = DateTime.now().setZone("America/New_York")
console.log(ny.toFormat("yyyy-MM-dd HH:mm:ss ZZZZ"))
// → "2026-03-09 07:00:00 Eastern Standard Time"
// Create from specific timezone:
const tokyo = DateTime.fromObject(
{ year: 2026, month: 3, day: 9, hour: 21 },
{ zone: "Asia/Tokyo" }
)
console.log(tokyo.toISO())
// → "2026-03-09T21:00:00.000+09:00"
// Convert between timezones:
const londonTime = tokyo.setZone("Europe/London")
console.log(londonTime.toFormat("HH:mm z"))
// → "12:00 GMT"
// UTC:
const utc = DateTime.utc(2026, 3, 9, 12, 0)
console.log(utc.setZone("America/Chicago").toFormat("h:mm a z"))
// → "6:00 AM CST"
Timezone comparison
import { DateTime } from "luxon"
// What time is it in multiple cities?
const now = DateTime.now()
const zones = ["America/New_York", "Europe/London", "Asia/Tokyo", "Australia/Sydney"]
zones.forEach((zone) => {
const time = now.setZone(zone)
console.log(`${zone}: ${time.toFormat("HH:mm (ZZZZ)")}`)
})
// → America/New_York: 07:00 (Eastern Standard Time)
// → Europe/London: 12:00 (Greenwich Mean Time)
// → Asia/Tokyo: 21:00 (Japan Standard Time)
// → Australia/Sydney: 23:00 (Australian Eastern Daylight Time)
// Timezone offset:
const ny = DateTime.now().setZone("America/New_York")
console.log(ny.offset) // → -300 (minutes from UTC)
console.log(ny.offsetNameShort) // → "EST"
console.log(ny.offsetNameLong) // → "Eastern Standard Time"
console.log(ny.zoneName) // → "America/New_York"
Duration and Interval
import { DateTime, Duration, Interval } from "luxon"
// Duration:
const duration = Duration.fromObject({ hours: 5, minutes: 30 })
console.log(duration.toFormat("h 'hours' m 'minutes'"))
// → "5 hours 30 minutes"
const meeting = DateTime.now().setZone("America/New_York")
const end = meeting.plus(duration)
console.log(end.toFormat("h:mm a z"))
// Interval:
const workday = Interval.fromDateTimes(
DateTime.fromObject({ hour: 9 }, { zone: "America/New_York" }),
DateTime.fromObject({ hour: 17 }, { zone: "America/New_York" })
)
console.log(workday.length("hours")) // → 8
console.log(workday.contains(DateTime.now().setZone("America/New_York")))
DST handling
import { DateTime } from "luxon"
// Luxon handles DST transitions automatically:
const beforeDST = DateTime.fromObject(
{ year: 2026, month: 3, day: 8, hour: 1, minute: 30 },
{ zone: "America/New_York" }
)
console.log(beforeDST.toFormat("h:mm a z")) // → "1:30 AM EST"
const afterDST = beforeDST.plus({ hours: 1 })
console.log(afterDST.toFormat("h:mm a z")) // → "3:30 AM EDT"
// (skips 2:00 AM — spring forward)
// Check if in DST:
console.log(afterDST.isInDST) // → true
Spacetime
Spacetime — lightweight timezone library:
Basic usage
import spacetime from "spacetime"
// Current time in a timezone:
const ny = spacetime.now("America/New_York")
console.log(ny.format("nice"))
// → "Mar 9th, 7:00am"
const tokyo = spacetime.now("Asia/Tokyo")
console.log(tokyo.format("nice"))
// → "Mar 9th, 9:00pm"
// Create specific date in timezone:
const date = spacetime("2026-03-09", "Europe/London")
console.log(date.format("iso"))
// → "2026-03-09T00:00:00.000+00:00"
Timezone conversion
import spacetime from "spacetime"
const meeting = spacetime("2026-03-09 3:00pm", "America/New_York")
// Convert to other timezones:
console.log(meeting.goto("Europe/London").format("time"))
// → "8:00pm"
console.log(meeting.goto("Asia/Tokyo").format("time"))
// → "5:00am" (next day)
console.log(meeting.goto("America/Los_Angeles").format("time"))
// → "12:00pm"
// Chain conversions:
const result = spacetime.now("America/New_York")
.goto("UTC")
.format("iso")
Human-friendly API
import spacetime from "spacetime"
const s = spacetime.now("America/New_York")
// Getters:
s.hour() // 7
s.minute() // 0
s.dayName() // "monday"
s.monthName() // "march"
s.timezone().name // "America/New_York"
// Setters (immutable):
const noon = s.hour(12).minute(0)
const nextWeek = s.add(1, "week")
const startOfDay = s.startOf("day")
const endOfMonth = s.endOf("month")
// Format:
s.format("nice-short") // "Mar 9, 7:00am"
s.format("nice-year") // "Mar 9th, 2026"
s.format("{month} {date-ordinal}, {year}") // "March 9th, 2026"
s.format("iso") // "2026-03-09T07:00:00.000-05:00"
DST handling
import spacetime from "spacetime"
const s = spacetime("2026-03-08", "America/New_York")
// Check DST:
console.log(s.isDST()) // false (before spring forward)
console.log(s.hasDST()) // true (this timezone uses DST)
console.log(s.offset()) // -300 (minutes, EST = UTC-5)
// After spring forward:
const after = s.add(1, "day")
console.log(after.isDST()) // true
console.log(after.offset()) // -240 (minutes, EDT = UTC-4)
// When does DST change?
const changes = s.timezone().change
// → { start: "March 8", back: "November 1" }
Feature Comparison
| Feature | date-fns-tz | Luxon | Spacetime |
|---|---|---|---|
| Type | date-fns extension | Standalone library | Standalone library |
| Timezone support | ✅ | ✅ (built-in) | ✅ (core feature) |
| IANA timezones | ✅ | ✅ | ✅ |
| DST handling | ✅ | ✅ | ✅ |
| Immutable | ✅ (native Date) | ✅ | ✅ |
| Duration/Interval | Via date-fns | ✅ | ❌ |
| Formatting | ✅ | ✅ | ✅ |
| Tree-shakeable | ✅ | ❌ | ❌ |
| Bundle size | ~5KB (+ date-fns) | ~70KB | ~40KB |
| TypeScript | ✅ | ✅ | ✅ |
| Intl API based | ✅ | ✅ | ✅ |
| Relative time | Via date-fns | ✅ | ✅ |
| Weekly downloads | ~5M | ~10M | ~500K |
Bundle Size & Browser Compatibility
Bundle size is a real consideration for timezone libraries because they need timezone data somewhere:
date-fns-tz (~6 KB gzipped): Uses the browser's native Intl.DateTimeFormat to resolve timezone offsets at runtime. No bundled timezone database — the runtime provides it. Result: tiny bundle, no outdated timezone data, but requires a modern browser or Node.js with full ICU data.
Luxon (~22 KB gzipped): Also relies on Intl for timezone data, but ships its own DateTime/Duration/Interval object model. The 22 KB is the object model — not a timezone database. Same Intl requirement as date-fns-tz.
Spacetime (~40 KB gzipped): Ships its own timezone database (~180 timezones). This makes Spacetime the heaviest option but the most self-contained — it works identically across all environments regardless of Intl support. Useful for environments with limited or inconsistent Intl implementations.
For most web applications in 2026, the Intl API is reliable. Use date-fns-tz or Luxon. Spacetime is worth the weight for server-side scripts with unusual Node.js builds or legacy browser targets.
DST Edge Cases and Why They Matter
Daylight Saving Time creates ambiguous moments: when clocks "fall back," the same local time occurs twice. When clocks "spring forward," some local times don't exist. Timezone libraries handle these differently:
// The ambiguous 1:30 AM during fall-back (clocks go 1:59 AM → 1:00 AM)
// This local time occurs twice — once in DST, once in standard time
// With date-fns-tz:
import { toZonedTime } from 'date-fns-tz';
// date-fns-tz follows the Intl convention: the later (standard) interpretation wins
// With Luxon:
import { DateTime } from 'luxon';
const ambiguous = DateTime.fromObject(
{ year: 2025, month: 11, day: 2, hour: 1, minute: 30 },
{ zone: 'America/New_York' }
);
// Luxon defaults to the earlier (DST) interpretation
// Use { keepLocalTime: true } for explicit control
// With Spacetime:
import spacetime from 'spacetime';
const s = spacetime('2025-11-02 01:30', 'America/New_York');
// Spacetime flags ambiguous times with a .isDST() utility
For most applications — booking systems, calendar apps, analytics dashboards — the one-hour DST window rarely causes issues. But for financial systems recording transaction times or healthcare scheduling, pick Luxon: it has the most explicit DST disambiguation API.
When to Use Each
Use date-fns-tz if:
- Already using date-fns for date operations
- Want tree-shakeable timezone functions
- Prefer working with native Date objects
- Need only timezone conversion and formatting
Use Luxon if:
- Need a comprehensive date/time library with timezones
- Want Duration, Interval, and timezone support built-in
- Migrating from Moment.js (same team, spiritual successor)
- Need the richest API for date/time operations
Use Spacetime if:
- Want the most human-friendly timezone API
- Need a lightweight library focused on timezones
- Building timezone conversion tools or world clocks
- Prefer natural language methods (dayName, monthName)
Migration Guide
From Moment-Timezone to Luxon
Luxon was built by one of Moment's core maintainers specifically to address Moment's mutability and bundle size problems. The mental model is similar but the API is immutable:
// moment-timezone (old — mutable, deprecated)
import moment from "moment-timezone"
const m = moment.tz("2026-03-09 15:00", "America/New_York")
console.log(m.format("h:mm a z")) // "3:00 pm EST"
m.add(1, "hour") // mutates m in place!
// Luxon (new — immutable)
import { DateTime } from "luxon"
const dt = DateTime.fromISO("2026-03-09T15:00:00", { zone: "America/New_York" })
console.log(dt.toFormat("h:mm a z")) // "3:00 PM EST"
const later = dt.plus({ hours: 1 }) // returns new DateTime, dt unchanged
Key differences: Luxon uses DateTime.fromISO() where Moment uses moment(), and Luxon format tokens differ from Moment's (h:mm a vs h:mm A). The Luxon migration guide maps every Moment method to its Luxon equivalent.
From date-fns to date-fns-tz
Adding timezone support to an existing date-fns codebase is purely additive — install date-fns-tz and replace format/parse with their timezone-aware versions:
// date-fns (no timezone)
import { format, parseISO } from "date-fns"
format(parseISO("2026-03-09T15:00:00Z"), "h:mm a")
// Shows in local system timezone — unreliable on servers
// date-fns-tz (timezone-aware)
import { formatInTimeZone, fromZonedTime } from "date-fns-tz"
formatInTimeZone(new Date("2026-03-09T15:00:00Z"), "America/New_York", "h:mm a")
// → "10:00 AM" (consistent regardless of server timezone)
Community Adoption in 2026
Luxon leads this category with approximately 10 million weekly downloads, driven by its position as the official Moment.js successor. Teams migrating away from Moment's deprecated status overwhelmingly choose Luxon for the API familiarity and the confidence that it is maintained by the same community.
date-fns-tz reaches around 5 million weekly downloads, inheriting popularity from date-fns (which is itself one of the most downloaded JavaScript packages). Teams already using date-fns add date-fns-tz rather than switching to a new library. The combination provides tree-shaking benefits that matter in browser bundles.
Spacetime sits at roughly 500,000 weekly downloads, serving a niche audience that values its human-readable API and lighter weight compared to Luxon. It is actively maintained and ships its own timezone data, making it a self-contained option for timezone conversion tools. Its lack of Duration and Interval support is a practical ceiling on adoption for complex scheduling applications.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on date-fns-tz v3.x, Luxon v3.x, and Spacetime v7.x.
Timezone Database Updates and IANA Maintenance
The IANA timezone database — the authoritative source of timezone rules for every region on Earth — updates multiple times per year as countries change their DST rules, move timezone boundaries, or abolish standard time entirely. How your timezone library handles these updates directly affects whether your application gives users correct times after a government changes the rules.
date-fns-tz and Luxon both delegate timezone data entirely to the JavaScript runtime's Intl API. This is the correct design choice in 2026. When the browser or Node.js updates its ICU data (which includes the IANA database), your application's timezone handling automatically becomes correct without any code or dependency changes. Node.js ships ICU data updates in point releases, and browser vendors ship them as part of regular browser updates. No action required from you.
The practical implication: if a country changes its DST rules (Morocco changed its rules twice in recent years, Russia has abolished DST, various Pacific island nations have shifted by ±12 hours), users of date-fns-tz and Luxon applications get the correct behavior as soon as they update Node.js or their browser. You do not ship a dependency update or rebuild your application.
Spacetime's bundled timezone database works differently. Spacetime ships a snapshot of IANA timezone data as part of its npm package. When IANA updates rules, Spacetime must publish a new npm version with updated data, and your application must update that dependency. Between the IANA update and your dependency update, your application may give incorrect times for the affected regions. For most enterprise applications, this gap — typically a few weeks between IANA update and Spacetime release — is acceptable. For applications where timezone accuracy is legally or financially significant (payroll, trading platforms, regulated scheduling), the Intl-delegating approach of date-fns-tz and Luxon is safer.
Leap seconds are a separate concern from timezone rules. The IANA database tracks timezone offsets but not leap seconds, which are added to UTC itself by the International Earth Rotation and Reference Systems Service. JavaScript's Date object does not represent leap seconds — it treats all minutes as exactly 60 seconds. The same is true for Temporal, date-fns, Luxon, and Spacetime. If your application needs sub-second accuracy across historical UTC periods, that is a specialized domain (financial systems, astronomical calculations) that none of these libraries address — you would use a dedicated library like tai-utc.
Server vs Client Timezone Handling
The timezone hydration mismatch problem is one of the more subtle bugs in React server-side rendering. The server renders a timestamp using the server's system timezone. The client receives the HTML and React hydrates it. If the client's timezone differs from the server's, the rendered timestamp no longer matches what React expects to hydrate, and React logs a hydration mismatch warning. In production, this can cause visible flicker as the client re-renders the timestamp in the user's local timezone.
The correct solution is to always render timestamps in a deterministic timezone on the server — either UTC or an explicit user-preferred timezone that you store in the session. Never let the server's system timezone affect what gets rendered:
// Bad: depends on server's system timezone
format(new Date(timestamp), 'h:mm a')
// Good: always UTC on server, let client display local timezone
import { formatInTimeZone } from 'date-fns-tz';
formatInTimeZone(new Date(timestamp), 'UTC', "yyyy-MM-dd HH:mm 'UTC'")
// Or: format with the user's stored timezone preference
formatInTimeZone(new Date(timestamp), user.timezone, 'h:mm a zzz')
Next.js and Remix applications that render timestamps should suppress timezone-sensitive rendering in server components or use suppressHydrationWarning on elements that will differ between server and client render. The cleaner approach is to render timezone-sensitive timestamps only in client components using a pattern like:
// components/LocalTime.tsx — client component, no SSR mismatch
'use client';
import { format } from 'date-fns';
export function LocalTime({ timestamp }: { timestamp: string }) {
// Renders in the browser's local timezone — no server mismatch
return <time dateTime={timestamp}>{format(new Date(timestamp), 'h:mm a')}</time>;
}
Luxon's DateTime.local() respects the system timezone, which makes it equally susceptible to the hydration mismatch on servers with non-UTC system timezones. The discipline required is the same regardless of which library you use: always be explicit about which timezone you're rendering in on the server. Store user timezone preferences (as IANA timezone strings) in your session or database, pass them down to rendering functions, and use formatInTimeZone or DateTime.setZone() rather than letting the runtime's system timezone make the decision implicitly.
For global applications serving users across many timezones, the recommended architecture is: store all timestamps as UTC in the database, transmit them as ISO 8601 strings with explicit Z or +00:00 suffix in your API, and convert to the user's local timezone only at the display layer. This is the single most reliable approach to avoiding timezone-related bugs in production, and it is compatible with all three libraries covered here.
Compare date/time libraries and developer tooling on PkgPulse →
See also: date-fns vs Luxon and date-fns vs Moment.js, acorn vs @babel/parser vs espree.