Problem: Moment.js is Deprecated and Breaking Modern Builds
Your codebase uses Moment.js for date handling, but it's been deprecated since 2020, adds 67KB to your bundle, and causes tree-shaking issues in modern build tools.
You'll learn:
- Why Temporal API replaces Moment.js patterns
- How to migrate common operations with AI assistance
- Handling timezone edge cases that break silently
Time: 30 min | Level: Intermediate
Why This Happens
Moment.js became deprecated because it's mutable, bloated, and doesn't support modern JavaScript features. The TC39 Temporal proposal (Stage 3, shipping in browsers 2025-2026) provides immutable date handling with proper timezone support.
Common symptoms:
- Build warnings about deprecated packages
- Bundle size over 60KB just for dates
- Timezone bugs in production (DST transitions fail silently)
- "Moment is not defined" errors after tree-shaking
Current status: Temporal API is available in Chrome 123+, Firefox 128+, Safari 18+. Use @js-temporal/polyfill for older browsers.
Solution
Step 1: Install Temporal Polyfill
# Add polyfill for browser compatibility
npm install @js-temporal/polyfill
# Types for TypeScript projects
npm install --save-dev @js-temporal/polyfill
Expected: Package installed without peer dependency warnings.
If it fails:
- Error: "Cannot resolve module": Check Node.js version is 18+ (Temporal requires modern JS)
- Conflict with date-fns: Fine to keep both during migration
Step 2: Set Up Polyfill
// src/temporal-polyfill.ts
import { Temporal } from '@js-temporal/polyfill';
// Make available globally for gradual migration
if (typeof globalThis.Temporal === 'undefined') {
globalThis.Temporal = Temporal;
}
export { Temporal };
// Import at app entry point
// src/main.ts or src/index.ts
import './temporal-polyfill';
Why this works: Polyfill only loads if browser doesn't support Temporal natively. Global assignment lets you migrate files incrementally.
Step 3: Use AI to Identify Migration Patterns
Create a migration prompt for your AI assistant:
// migration-prompt.md
/**
* Convert this Moment.js code to Temporal API:
*
* Requirements:
* - Use Temporal.PlainDate for date-only operations
* - Use Temporal.ZonedDateTime for timezone-aware operations
* - Use Temporal.Instant for timestamps
* - Preserve timezone handling behavior
* - Keep the same output format
*
* Common patterns:
* moment() -> Temporal.Now.zonedDateTimeISO()
* moment.utc() -> Temporal.Now.instant()
* .format() -> .toString() or .toLocaleString()
* .add() -> .add({ days: 1 })
* .diff() -> .until().total('days')
*/
Feed this to Claude/GPT with your code: This context ensures AI understands timezone requirements.
Step 4: Migrate Common Patterns
Creating Dates
// ❌ Moment.js
const now = moment();
const utcNow = moment.utc();
const specificDate = moment('2026-02-15');
// ✅ Temporal API
const now = Temporal.Now.zonedDateTimeISO();
const utcNow = Temporal.Now.instant();
const specificDate = Temporal.PlainDate.from('2026-02-15');
Why different types: PlainDate has no time/timezone, ZonedDateTime respects local time, Instant is always UTC.
Date Math
// ❌ Moment.js (mutable - bugs waiting to happen)
const futureDate = moment().add(7, 'days');
const pastDate = moment().subtract(1, 'month');
// ✅ Temporal API (immutable - safe)
const futureDate = Temporal.Now.plainDateISO().add({ days: 7 });
const pastDate = Temporal.Now.plainDateISO().subtract({ months: 1 });
Critical difference: Moment mutates the original object. Temporal always returns new instances.
Formatting
// ❌ Moment.js
const formatted = moment().format('YYYY-MM-DD HH:mm:ss');
const userFriendly = moment().format('MMMM Do YYYY, h:mm a');
// ✅ Temporal API
const formatted = Temporal.Now.plainDateTimeISO().toString();
// -> "2026-02-15T14:30:00"
const userFriendly = Temporal.Now.zonedDateTimeISO().toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
// -> "February 15, 2026, 2:30 PM"
AI migration tip: Moment's format strings don't translate directly. Use toLocaleString() with Intl.DateTimeFormat options instead.
Timezone Handling
// ❌ Moment.js (fragile with moment-timezone)
const nyTime = moment.tz('2026-02-15 14:30', 'America/New_York');
const converted = nyTime.clone().tz('Europe/London');
// ✅ Temporal API (built-in, robust)
const nyTime = Temporal.ZonedDateTime.from({
timeZone: 'America/New_York',
year: 2026,
month: 2,
day: 15,
hour: 14,
minute: 30
});
const converted = nyTime.withTimeZone('Europe/London');
// Automatically handles DST transitions
Why this matters: Temporal correctly handles DST transitions, leap seconds, and calendar quirks that break Moment.
Calculating Differences
// ❌ Moment.js
const start = moment('2026-01-01');
const end = moment('2026-02-15');
const daysDiff = end.diff(start, 'days');
// ✅ Temporal API
const start = Temporal.PlainDate.from('2026-01-01');
const end = Temporal.PlainDate.from('2026-02-15');
const daysDiff = start.until(end).total({ unit: 'day' });
// -> 45
Gotcha: until() returns a Duration object. Call .total() to get numeric value.
Step 5: AI-Assisted Bulk Migration
Use this workflow with Claude or GPT:
// 1. Extract all Moment usage
grep -r "moment(" src/ --include="*.ts" --include="*.tsx" > moment-usage.txt
// 2. Create migration batch prompt
// For each file, ask AI:
"Migrate this file from Moment.js to Temporal API.
File: src/utils/dateHelpers.ts
Original code: [paste code]
Use the migration patterns from Step 4."
// 3. Verify each AI suggestion
npm run test -- dateHelpers.test.ts
Pro tip: Migrate one utility file at a time. Test before moving to the next.
Step 6: Handle Edge Cases
// Edge Case 1: Relative time ("2 hours ago")
// Moment.js: moment().fromNow()
// Temporal: No built-in helper, use Intl.RelativeTimeFormat
function timeAgo(date: Temporal.ZonedDateTime): string {
const now = Temporal.Now.zonedDateTimeISO(date.timeZone);
const duration = date.until(now);
const hours = duration.total({ unit: 'hour' });
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
if (hours < 1) {
const minutes = duration.total({ unit: 'minute' });
return formatter.format(-Math.floor(minutes), 'minute');
}
if (hours < 24) {
return formatter.format(-Math.floor(hours), 'hour');
}
const days = duration.total({ unit: 'day' });
return formatter.format(-Math.floor(days), 'day');
}
// Usage
const past = Temporal.ZonedDateTime.from('2026-02-15T12:00[America/New_York]');
console.log(timeAgo(past)); // "2 hours ago"
// Edge Case 2: Start/End of day
// Moment.js: moment().startOf('day')
// Temporal: Use PlainDate + conversion
const date = Temporal.Now.zonedDateTimeISO();
const startOfDay = date.toPlainDate().toZonedDateTime({
timeZone: date.timeZone,
plainTime: Temporal.PlainTime.from('00:00:00')
});
const endOfDay = date.toPlainDate().toZonedDateTime({
timeZone: date.timeZone,
plainTime: Temporal.PlainTime.from('23:59:59')
});
If AI suggests wrong patterns:
- "Use .toInstant() for dates": Wrong - loses timezone info, use ZonedDateTime
- "Call .getTime()": Temporal doesn't have this, use
.epochMilliseconds - "Format with moment format strings": Use Intl.DateTimeFormat options instead
Verification
Test 1: Bundle Size
# Before migration
npm run build
# Check bundle size in dist/
# After migration (remove moment)
npm uninstall moment moment-timezone
npm run build
You should see: 60-70KB reduction in bundle size.
Test 2: Timezone Accuracy
// Test DST transition (March 10, 2026 - US DST starts)
import { Temporal } from '@js-temporal/polyfill';
const beforeDST = Temporal.ZonedDateTime.from(
'2026-03-08T12:00[America/New_York]'
);
const afterDST = beforeDST.add({ days: 3 });
console.log(beforeDST.offset); // "-05:00" (EST)
console.log(afterDST.offset); // "-04:00" (EDT) - correct!
// Moment.js often gets this wrong with .add()
Test 3: Run Existing Date Tests
npm run test -- --grep="date|time|moment"
You should see: All tests passing. If tests fail, check:
- Mutable vs immutable behavior (Temporal never mutates)
- Format string mismatches (use toLocaleString())
- Timezone not specified (add explicit timeZone parameter)
What You Learned
- Temporal API provides three types: PlainDate (no time), ZonedDateTime (with timezone), Instant (UTC timestamp)
- All Temporal operations are immutable - no accidental mutations
- AI assistants can migrate patterns but need explicit timezone requirements
- Polyfill adds ~30KB but enables gradual migration
Limitations:
- Older browsers need polyfill (adds bundle size temporarily)
- No drop-in replacement - requires understanding date type distinctions
- AI may suggest
.getTime()patterns that don't exist in Temporal
When NOT to migrate:
- If you're on Node.js <18 and can't upgrade (use date-fns instead)
- If you need relative time formatting (Temporal delegates to Intl.RelativeTimeFormat)
- If your team isn't ready to learn immutable patterns
Quick Reference
| Moment.js | Temporal API |
|---|---|
moment() | Temporal.Now.zonedDateTimeISO() |
moment.utc() | Temporal.Now.instant() |
moment('2026-02-15') | Temporal.PlainDate.from('2026-02-15') |
.add(7, 'days') | .add({ days: 7 }) |
.format('YYYY-MM-DD') | .toString() or .toPlainDate().toString() |
.diff(other, 'days') | .until(other).total({ unit: 'day' }) |
.isBefore(other) | Temporal.PlainDate.compare(a, b) < 0 |
.clone() | Not needed - all operations return new instances |
AI Migration Checklist
When reviewing AI-generated Temporal code, verify:
- Correct type used (PlainDate vs ZonedDateTime vs Instant)
- Timezone explicitly specified for ZonedDateTime
-
.total()called on Duration results - No mutation assumptions (no
.set(), only.with()) - Format strings converted to Intl options
- Tests updated for immutable behavior
- Polyfill imported in entry point
Tested with @js-temporal/polyfill 0.4.4, TypeScript 5.5, Node.js 22.x, Chrome 123+
Browser compatibility: Native support in Chrome 123+, Firefox 128+, Safari 18+. Use polyfill for older browsers.
Bundle impact: Polyfill adds ~30KB gzipped. Native Temporal adds 0KB. Removing Moment.js saves ~67KB minified.