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

Installing a polyfill sounds like a one-line npm install, and it mostly is — but the Temporal polyfill has a couple of properties that interact awkwardly with a modern bundler like Vite if you are not deliberate. It ships both CommonJS and ESM entry points, and it is the kind of dependency whose behavior (date arithmetic) you never want to change silently across a patch release, because a subtle change in how it balances durations or resolves a calendar could shift results in your app without any code change on your part. So the setup is less about getting it to import and more about getting it to import reproducibly and without breaking Vite's dev-server hot-module-reload.

The two friction points are dependency pre-bundling and version pinning. Vite pre-bundles dependencies with esbuild for fast cold starts and stable HMR, and a package with mixed CJS/ESM interop occasionally needs to be named explicitly in optimizeDeps.include so esbuild handles it once rather than tripping over it during reloads. And because arithmetic correctness is a hard requirement, pinning the exact version keeps a npm install months later from pulling a different polyfill build that computes something differently. Neither is hard, but both are easy to omit and annoying to debug after the fact.

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.

Direct import beats global timing in ViteIn dev, a feature module may load before main.ts assigns globalThis.Temporal, so reading the global throws. Importing Temporal directly from the polyfill resolves the dependency before the module body runs, so it always works.Module load order in devGlobal assignment (fragile)feature.tsreads globalmain.tssets globalruns first -> ReferenceErrorDirect import (reliable)feature.tsimport Temporalpolyfillreadyresolved before body runsoptimizeDeps.include: ['@js-temporal/polyfill']pre-bundles the CJS/ESM interop so HMR does not 504test under TZ=UTC and TZ=America/New_York for parity

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

Add the polyfill in ViteInstall and import once at the entryAdd the polyfill in Vitenpm ipolyfillimport inmain.tsTemporalavailable

Full Production Version

The robust setup is three deliberate choices. Install with an exact pin (--save-exact) so the arithmetic implementation cannot change under you without an intentional, reviewed version bump — for a library whose whole job is producing correct date results, a silent patch is a risk not a convenience. Add @js-temporal/polyfill to optimizeDeps.include in vite.config.ts so esbuild pre-bundles it up front, which smooths the CJS/ESM interop and keeps HMR from re-optimizing mid-session. Then import { Temporal } from the package at the points of use, or expose it through a small internal module so there is one place that owns the polyfill dependency.

For production builds the same pinning discipline pays off in reproducibility: a locked version plus a committed lockfile means the bundle you ship is byte-for-byte the arithmetic you tested. If you are targeting environments that may eventually ship Temporal natively, structuring access through one internal module also gives you a single seam to swap the polyfill for the native global later, without touching call sites. Keep the polyfill out of any code path that must run before the bundle loads, and verify after setup that a representative Temporal operation actually executes in both dev and a production build, so an interop misconfiguration surfaces immediately rather than in a user's browser.

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.

Native-aware Vite setupFeature-detect native Temporal; lazy-load polyfill otherwiseNative-aware Vite setupbootnative?import()polyfilltyped API

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

Vite setup assertionsTemporal resolves in dev and buildAssertions that prove the edge casedev serverTemporal definedprod buildchunk presentnative engine0 bytestypesresolve

Common Pitfalls

The first pitfall is not pinning the version. A polyfill that computes dates is exactly the dependency you do not want floating on a caret range, because a patch that changes duration balancing or calendar resolution would alter your app's output with no code change to point at during debugging. Use an exact pin and a committed lockfile. The second is omitting optimizeDeps.include, which for a mixed CJS/ESM package can cause Vite to re-optimize dependencies mid-session and destabilize HMR, producing confusing "why did my dev server just reload everything" behavior.

A third mistake is importing the polyfill in a way that pulls it into a bundle chunk that loads too early or duplicates it across chunks; centralize the import in one module so there is a single owner. A fourth is assuming the polyfill and a future native Temporal are interchangeable without a seam — route access through one internal module so the eventual swap is a one-file change. Finally, do not forget to actually exercise a Temporal call in both the dev server and a production build as part of setup verification; an interop problem that only manifests in the production bundle is far cheaper to catch at configuration time than after deploy.

Vite polyfill pitfallsImporting eagerly everywhereWrongRightimport in every moduleduplicated costimport once at entrysingle copyno native detectionships alwaysdetect native firstskip when supported

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.

How do I install and configure the Temporal polyfill in a Vite project?

Install @js-temporal/polyfill with an exact version pin (npm install @js-temporal/polyfill --save-exact), add it to optimizeDeps.include in vite.config.ts so esbuild pre-bundles it and HMR stays stable, then import { Temporal } from the package where you use it. Pinning protects you from a patch silently changing date arithmetic, and pre-bundling smooths the package's CJS/ESM interop.

Why should I pin the exact version of the Temporal polyfill?

Because the polyfill's job is producing correct date results, and a patch release that changes how it balances durations or resolves a calendar could shift your app's output with no code change on your part — a bug with nothing to point at. An exact pin plus a committed lockfile means the arithmetic you ship is the arithmetic you tested, and version changes become intentional, reviewed events.

Why add the polyfill to Vite's optimizeDeps.include?

Because the package ships mixed CommonJS and ESM entry points, and naming it in optimizeDeps.include tells esbuild to pre-bundle it once up front. That handles the CJS/ESM interop cleanly and prevents Vite from re-optimizing dependencies mid-session, which would otherwise destabilize hot-module-reload and cause confusing full-page reloads during development.