Install and Configure Temporal Polyfill in Vite
To use the Temporal API in a Vite app today, install @js-temporal/polyfill, add it to optimizeDeps.include so pre-bundling does not crash HMR, import Temporal directly in each module, and verify under a TZ matrix. Part of Getting Started with the Temporal API.
Why This Scenario Is Tricky
Two Vite-specific failure modes bite teams adding the polyfill. First, Vite pre-bundles dependencies with esbuild for dev. The polyfill ships as CommonJS/ESM interop, and if it is not listed in optimizeDeps.include, Vite may fail to convert it on first request — the dev server returns 504 (Outdated Optimize Dep) and HMR breaks. Second, because Vite serves ES modules out of order in dev, any module that reads a global Temporal can execute before main.ts has assigned it, throwing ReferenceError: Temporal is not defined. The robust fix is to import Temporal directly in every date-critical module rather than relying on global assignment timing.
A third, subtler issue is environment parity: CI runners default to TZ=UTC while developer machines use a local zone. Tests that pass locally then fail in CI because a ZonedDateTime resolves a different offset. The fix is to test under an explicit TZ matrix from the start.
Minimal Working Solution
Install with a pinned version, then add the one Vite config line that prevents the HMR crash.
npm install @js-temporal/polyfill --save-exact # pin: arithmetic must not change silently
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
// Pre-bundle so esbuild handles the CJS/ESM interop and HMR stays stable.
include: ['@js-temporal/polyfill'],
},
});
// any date-critical module — import directly, do not depend on a global
import { Temporal } from '@js-temporal/polyfill';
const today = Temporal.Now.plainDateISO(); // ISO 8601 (Gregorian) calendar
Full Production Version
Add a manual chunk so the polyfill caches separately, conditional loading so native-Temporal browsers skip the download, and TypeScript types for the global if you choose to expose one.
// vite.config.ts
import { defineConfig } from 'vite';
export default defineConfig({
optimizeDeps: {
include: ['@js-temporal/polyfill'], // stabilize dev pre-bundling
},
build: {
rollupOptions: {
output: {
manualChunks: {
// Split the polyfill into its own long-cacheable chunk.
'temporal-polyfill': ['@js-temporal/polyfill'],
},
},
},
},
});
// src/temporal-loader.ts — download the polyfill only when there is no native Temporal
export async function loadTemporal(): Promise<typeof import('@js-temporal/polyfill').Temporal> {
if (typeof globalThis.Temporal !== 'undefined') {
return globalThis.Temporal as any; // native (Chrome 144+, Firefox 139+) — no download
}
const { Temporal } = await import('@js-temporal/polyfill'); // dynamic import = own chunk
(globalThis as any).Temporal = Temporal; // assign yourself; the polyfill does not
return Temporal;
}
// src/temporal.d.ts — only needed if you read the global Temporal anywhere
import type { Temporal as TemporalType } from '@js-temporal/polyfill';
declare global {
const Temporal: typeof TemporalType; // removes TS2304 and restores autocomplete
interface Window { Temporal: typeof TemporalType; }
}
export {};
If date logic is on the critical path everywhere, prefer an eager import in main.ts over loadTemporal() — the synchronous import removes the race condition entirely, and the cost is modest. For exactly how small that cost is and how to trim it, see Temporal polyfill bundle size and tree-shaking.
Verification Snippet
Prove the polyfill resolves correctly and that arithmetic is independent of the host zone.
// src/verify-temporal.ts
import { Temporal } from '@js-temporal/polyfill';
export function verifyTemporal(): void {
// ZonedDateTime resolves the IANA offset; epochNanoseconds is a BigInt.
const zdt = Temporal.ZonedDateTime.from(
'2023-11-05T01:30:00[America/New_York]',
{ disambiguation: 'compatible' } // fall-back overlap — pick the legacy-compatible instant
);
console.assert(typeof zdt.epochNanoseconds === 'bigint', 'epochNanoseconds is BigInt');
console.assert(zdt.toInstant().toString().endsWith('Z'), 'Instant serializes as UTC');
// Leap day is valid and survives a round-trip to string.
const feb29 = Temporal.PlainDate.from('2024-02-29');
console.assert(feb29.toString() === '2024-02-29', 'leap day valid');
console.log('Temporal runtime OK');
}
# Run under both zones so a host-zone assumption fails in CI, not in production.
TZ=UTC node --import tsx src/verify-temporal.ts
TZ=America/New_York node --import tsx src/verify-temporal.ts
Common Pitfalls
- Omitting
optimizeDeps.include. Wrong: leaving Vite to discover the polyfill lazily, which 504s on first request. Right: list'@js-temporal/polyfill'inoptimizeDeps.include. - Reading a global before it is set. Wrong: a feature module references
globalThis.Temporalwhilemain.tsmay not have run. Right:import { Temporal } from '@js-temporal/polyfill'in that module. - Lazy load without a guard. Wrong: calling
loadTemporal()then synchronously usingTemporalbefore the dynamic import resolves. Right:await loadTemporal(), or import eagerly in the entry point. - Replacing
Date.prototypeglobally. Wrong: swapping theDateconstructor to "force" Temporal, which breaks analytics and UI libraries. Right: use Temporal alongsideDateand bridge at boundaries.
FAQ
Why does Vite throw "Temporal is not defined" in dev?
A module read the global Temporal before main.ts assigned it, or the polyfill was not pre-bundled. Import Temporal directly from @js-temporal/polyfill in each module and add the package to optimizeDeps.include.
Can the polyfill coexist with date-fns or Day.js?
Yes. Keep them in separate modules and migrate incrementally. Bridge with Temporal.Instant.fromEpochMilliseconds(date.getTime()) going in and new Date(Number(instant.epochMilliseconds)) coming out.
How do I keep DST tests stable in CI?
Never depend on the runner's TZ. Pass explicit IANA identifiers to Temporal constructors and run the suite under a TZ matrix (at minimum UTC and one DST zone) so host-zone drift surfaces immediately.