After 9 years in development and countless TC39 meetings, the JavaScript Temporal API officially reached Stage 4 on March 11, 2026, locking it into the ES2026 specification. That means it's no longer a proposal — it's the future of date and time handling in JavaScript, and you should start using it in your Node.js APIs today.
If you've ever shipped a date-related bug in production — DST edge cases, wrong timezone conversions, silent mutation bugs from Date.setDate() — you're not alone. The Date object was designed in 1995, copied from Java, and has been causing developer pain ever since. Temporal is the fix.
This guide covers how to use the ES2026 Temporal API in Node.js REST APIs with practical, real-world patterns: storing timestamps correctly, comparing durations, handling multi-timezone scheduling, and returning ISO 8601 dates from your endpoints.
What's Wrong with Date in 2026?
Let's be blunt. The JavaScript Date object is broken by design:
// Classic confusion: is this UTC or local?
const d = new Date('2026-04-01');
console.log(d.getDate()); // Could be March 31 in UTC-5 timezones!
// Mutable by default — easy to introduce bugs
const start = new Date();
const end = start; // Same reference!
end.setDate(end.getDate() + 7); // Mutates start too
// No timezone support
new Date().toLocaleString('en-US', { timeZone: 'Asia/Ho_Chi_Minh' });
// Works, but fragile — no first-class TZ type
These aren't edge cases. They're production bugs waiting to happen. Every "scheduled for Monday" bug, every "appointment shows wrong time in a different region" complaint traces back to the Date object's fundamental design flaws.
The Temporal Fix: Type-Safe, Immutable, Timezone-Aware
Temporal introduces distinct types for distinct concerns. No more guessing:
| Type | Use Case |
|---|---|
Temporal.Instant |
A precise UTC moment (like a Unix timestamp) |
Temporal.ZonedDateTime |
A moment + timezone (for scheduling) |
Temporal.PlainDate |
A calendar date (no time, no timezone) |
Temporal.PlainTime |
A wall-clock time (no date, no timezone) |
Temporal.PlainDateTime |
Date + time without timezone info |
Temporal.Duration |
A length of time (e.g., "2 hours 30 minutes") |
All Temporal objects are immutable. Operations return new objects. No more mutation surprises.
Getting Started: Install the Polyfill
While Temporal is ES2026 standard, native support in Node.js 24 requires the --harmony-temporal flag (V8 implementation is in progress as of April 2026). For production APIs, use the official polyfill:
npm install @js-temporal/polyfill
// In Node.js 24 with --harmony-temporal flag (experimental):
// const { Temporal } = globalThis;
// For production today (polyfill approach):
import { Temporal } from '@js-temporal/polyfill';
// Or CommonJS:
const { Temporal } = require('@js-temporal/polyfill');
Note: Major browsers (Chrome 129+, Firefox 139+, Safari 18.4+) have started shipping native Temporal support as of early 2026. Node.js native support without a flag is expected in Node.js 24 LTS updates later in 2026.
Pattern 1: Storing and Returning Timestamps in REST APIs
The most common mistake: using new Date() and calling .toISOString() without thinking about what you're actually storing.
The wrong way:
// What timezone is this? What format is the client expecting?
app.get('/events/:id', async (req, res) => {
const event = await db.query('SELECT * FROM events WHERE id = $1', [req.params.id]);
res.json({
...event,
startsAt: event.starts_at.toISOString(), // Loses timezone info!
});
});
The Temporal way — explicit and unambiguous:
import { Temporal } from '@js-temporal/polyfill';
app.get('/events/:id', async (req, res) => {
const event = await db.query('SELECT * FROM events WHERE id = $1', [req.params.id]);
// Convert DB timestamp (stored as UTC) to Temporal.Instant
const instant = Temporal.Instant.fromEpochMilliseconds(
event.starts_at.getTime()
);
// Return UTC instant (canonical form for APIs)
res.json({
id: event.id,
title: event.title,
startsAt: instant.toString(), // "2026-06-15T09:00:00Z" — always UTC, always unambiguous
timezone: event.timezone, // Store the original timezone separately
});
});
Even better — return timezone-aware datetimes:
app.get('/events/:id', async (req, res) => {
const event = await db.query('SELECT * FROM events WHERE id = $1', [req.params.id]);
const instant = Temporal.Instant.fromEpochMilliseconds(
event.starts_at.getTime()
);
// Convert to the event's original timezone
const zdt = instant.toZonedDateTimeISO(event.timezone);
res.json({
id: event.id,
title: event.title,
startsAt: {
utc: instant.toString(),
local: zdt.toString(), // "2026-06-15T16:00:00+07:00[Asia/Ho_Chi_Minh]"
timezone: event.timezone,
},
});
});
This is the pattern used by modern scheduling APIs — always store UTC, always return the original timezone context alongside it.
Pattern 2: Accepting and Validating Date Inputs
Validating date inputs with new Date() is fragile — it silently accepts bad input. Temporal throws on invalid data, making it a natural validation layer.
import { Temporal } from '@js-temporal/polyfill';
function parseEventInput(body) {
let startDate, endDate;
try {
// Strict ISO 8601 parsing — throws on invalid input
startDate = Temporal.Instant.from(body.startsAt);
} catch (e) {
throw new Error(`Invalid startsAt: "${body.startsAt}" is not a valid ISO 8601 timestamp`);
}
try {
endDate = Temporal.Instant.from(body.endsAt);
} catch (e) {
throw new Error(`Invalid endsAt: "${body.endsAt}" is not a valid ISO 8601 timestamp`);
}
// Validate logical ordering
if (Temporal.Instant.compare(startDate, endDate) >= 0) {
throw new Error('endsAt must be after startsAt');
}
// Validate minimum duration (e.g., events must be at least 15 minutes)
const duration = startDate.until(endDate);
if (duration.total('minutes') < 15) {
throw new Error('Event must be at least 15 minutes long');
}
return { startDate, endDate };
}
app.post('/events', async (req, res) => {
try {
const { startDate, endDate } = parseEventInput(req.body);
// Store as epoch milliseconds in the database
await db.query(
'INSERT INTO events (title, starts_at, ends_at) VALUES ($1, $2, $3)',
[
req.body.title,
new Date(startDate.epochMilliseconds),
new Date(endDate.epochMilliseconds),
]
);
res.status(201).json({ message: 'Event created' });
} catch (e) {
res.status(400).json({ error: e.message });
}
});
Pattern 3: Multi-Timezone Scheduling Logic
This is where Temporal truly shines. Building a scheduling API that works across timezones is notoriously painful. Here's a clean pattern for "find available slots" in a given user's timezone:
import { Temporal } from '@js-temporal/polyfill';
/**
* Get the next 7 available booking slots, in the user's local timezone.
* Business hours: 9 AM - 5 PM, Monday-Friday
*/
function getAvailableSlots(userTimezone, existingBookings = []) {
const slots = [];
let current = Temporal.Now.zonedDateTimeISO(userTimezone);
// Start from the next full hour
current = current.round({ smallestUnit: 'hour', roundingMode: 'ceil' });
while (slots.length < 7) {
const hour = current.hour;
const dayOfWeek = current.dayOfWeek; // 1=Mon, 7=Sun
// Skip weekends
if (dayOfWeek <= 5 && hour >= 9 && hour < 17) {
const slotEnd = current.add({ hours: 1 });
// Check if slot is already booked
const isBooked = existingBookings.some(booking => {
const bookingStart = Temporal.Instant.from(booking.startsAt)
.toZonedDateTimeISO(userTimezone);
return Temporal.ZonedDateTime.compare(bookingStart, current) === 0;
});
if (!isBooked) {
slots.push({
startsAt: current.toInstant().toString(),
endsAt: slotEnd.toInstant().toString(),
localTime: current.toPlainTime().toString(),
localDate: current.toPlainDate().toString(),
timezone: userTimezone,
});
}
}
current = current.add({ hours: 1 });
}
return slots;
}
app.get('/slots', async (req, res) => {
const { timezone = 'UTC' } = req.query;
try {
// Validate the timezone
Temporal.TimeZone.from(timezone); // Throws if invalid
} catch (e) {
return res.status(400).json({ error: `Invalid timezone: "${timezone}"` });
}
const bookings = await db.query('SELECT * FROM bookings WHERE starts_at > NOW()');
const slots = getAvailableSlots(timezone, bookings.rows);
res.json({ timezone, slots });
});
Pattern 4: Duration Calculations for Billing and Rate Limiting
import { Temporal } from '@js-temporal/polyfill';
// API usage tracking — calculate billable time
app.get('/usage/:userId', async (req, res) => {
const sessions = await db.query(
'SELECT started_at, ended_at FROM api_sessions WHERE user_id = $1',
[req.params.userId]
);
let totalDuration = new Temporal.Duration();
for (const session of sessions.rows) {
const start = Temporal.Instant.fromEpochMilliseconds(session.started_at.getTime());
const end = Temporal.Instant.fromEpochMilliseconds(session.ended_at.getTime());
const sessionDuration = start.until(end, { largestUnit: 'hours' });
totalDuration = totalDuration.add(sessionDuration);
}
const normalized = Temporal.Duration.from(totalDuration);
res.json({
userId: req.params.userId,
totalUsage: {
hours: normalized.hours,
minutes: normalized.minutes,
seconds: normalized.seconds,
totalMinutes: Math.floor(normalized.total('minutes')),
},
billableUnits: Math.ceil(normalized.total('minutes') / 15),
});
});
Pattern 5: Relative Time Without moment.js
import { Temporal } from '@js-temporal/polyfill';
function relativeTime(isoString) {
const then = Temporal.Instant.from(isoString);
const now = Temporal.Now.instant();
const isFuture = Temporal.Instant.compare(then, now) > 0;
const absDuration = isFuture
? now.until(then, { largestUnit: 'years' })
: then.until(now, { largestUnit: 'years' });
if (absDuration.years >= 1) return `${absDuration.years}y ${isFuture ? 'from now' : 'ago'}`;
if (absDuration.months >= 1) return `${absDuration.months}mo ${isFuture ? 'from now' : 'ago'}`;
if (absDuration.weeks >= 1) return `${absDuration.weeks}w ${isFuture ? 'from now' : 'ago'}`;
if (absDuration.days >= 1) return `${absDuration.days}d ${isFuture ? 'from now' : 'ago'}`;
if (absDuration.hours >= 1) return `${absDuration.hours}h ${isFuture ? 'from now' : 'ago'}`;
if (absDuration.minutes >= 1) return `${absDuration.minutes}m ${isFuture ? 'from now' : 'ago'}`;
return 'just now';
}
Quick Reference: Date → Temporal Migrations
// Get current time
// Before: new Date()
// After: Temporal.Now.instant()
// Parse ISO string
// Before: new Date('2026-04-01T09:00:00Z')
// After: Temporal.Instant.from('2026-04-01T09:00:00Z')
// Add time
// Before: new Date(date.getTime() + 7 * 24 * 60 * 60 * 1000)
// After: instant.add({ days: 7 })
// Compare dates
// Before: date1 > date2
// After: Temporal.Instant.compare(instant1, instant2) > 0
// Format for API response
// Before: date.toISOString()
// After: instant.toString()
Conclusion
The ES2026 Temporal API is the biggest improvement to JavaScript date handling since the language was created. With Stage 4 confirmed on March 11, 2026, and the polyfill production-ready today, there's no reason to wait.
Start with @js-temporal/polyfill. Use Temporal.Instant for UTC storage, Temporal.ZonedDateTime for scheduling logic, and Temporal.Duration for billing and rate limiting. Your future self — and your API consumers — will thank you.
Building APIs? 1xAPI provides developer tools and API infrastructure.
Top comments (0)