Temporal Polyfill Bundle Size & Tree-Shaking
How to stop @js-temporal/polyfill from bloating your initial JavaScript payload, using dynamic import, route-level code-splitting, and native feature detection. Part of Modern Date Logic with the Temporal API.
The Temporal proposal fixes the well-known failures of legacy Date — implicit timezone assumptions, mutable state, and broken DST math — but the polyfill that ships those guarantees today is not small. Drop it into your entry point and it lands in the main chunk, where it inflates time-to-interactive on every route, including pages that never touch a date. The result is a measurable regression: a slower first paint, a bigger initial download on mobile, and a Lighthouse score that drops for a feature most of your users never exercise. The fix is not to tree-shake the polyfill into smaller pieces — it largely resists that — but to control when and whether it loads at all.
This guide explains why the polyfill is effectively monolithic, then walks through the strategies that actually move the needle: lazy import(), splitting date-heavy routes, measuring with a bundle analyzer, and detecting native Temporal so modern browsers download nothing. For the framework-specific setup that precedes this, see Install and Configure Temporal Polyfill in Vite.
What "bundle size" means here
The standard advice for shrinking dependencies is tree-shaking: import only the named exports you use, let the bundler drop dead code, and ship the minimum. That advice mostly does not apply to @js-temporal/polyfill. The package exposes a single Temporal namespace whose members share a dense web of internal helpers — calendar resolution, ISO arithmetic, rounding, and a bundled copy of the IANA timezone database. Importing Temporal.PlainDate pulls in the same core as importing Temporal.ZonedDateTime, because they share the same arithmetic and calendar engine.
The diagram below contrasts the two loading strategies that do change your numbers: eager import (polyfill in the main chunk, downloaded before first paint) versus dynamic import (polyfill in a separate chunk, fetched only when a date feature runs).
API reference mini-table
| Tool / API | Signature | Returns | Bundle / timezone caveat |
|---|---|---|---|
import('@js-temporal/polyfill') |
dynamic import expression | Promise<Module> |
Bundler emits a separate chunk; keep all Temporal usage behind the resolved module |
globalThis.Temporal |
property access | typeof Temporal | undefined |
Native engine when present; carries the host's bundled tzdata, not yours |
manualChunks (Rollup/Vite) |
(id) => string or record |
chunk grouping | Isolates the polyfill but does not make it lazy unless the import is dynamic |
splitChunks (webpack) |
optimization.splitChunks config |
named cache group | Same: splitting ≠ deferring; pair with import() |
Temporal.Now.timeZoneId() |
() => string |
IANA zone string e.g. "America/New_York" |
Reads host zone; on the server this is the server zone — pass explicit zones instead |
The polyfill is monolithic by design
Before optimizing, understand what you cannot do. @js-temporal/polyfill ships as a tightly coupled unit for two structural reasons.
First, the IANA timezone database. To resolve America/New_York to a correct offset on any given instant — including historical DST rule changes — the polyfill embeds tzdata. This is a large, indivisible blob: you cannot tree-shake away "the timezones I don't use," because the rule set is queried at runtime by string ID, and the bundler has no way to know which IANA zones your users will pass. A booking app that only ever shows Europe/London still ships every zone, because nothing statically proves the others are dead.
Second, the calendar and arithmetic engine. PlainDate, PlainDateTime, ZonedDateTime, Instant, and Duration are not independent modules — they convert into one another and share rounding, balancing, and calendar-resolution code. Pull in any one type and the shared core comes with it.
import { Temporal } from '@js-temporal/polyfill';
// Importing a single, "small" type still pulls the shared core + tzdata.
// There is no `@js-temporal/polyfill/plain-date` entry point that ships less.
const today = Temporal.Now.plainDateISO(); // shared calendar engine loaded
The practical consequence: stop trying to import less, and start controlling when the whole thing loads.
Approach A: eager import (the default cost)
The most common setup imports the polyfill in the entry point and assigns it to the global. Every byte lands in the initial chunk.
// src/main.ts — eager: polyfill is in the main bundle, downloaded on first paint
import { Temporal } from '@js-temporal/polyfill';
if (typeof globalThis.Temporal === 'undefined') {
// Assign once so the rest of the app can read the global Temporal
(globalThis as any).Temporal = Temporal;
}
This is correct and simple, and for a date-centric application — a calendar, a scheduler, a payroll tool where nearly every route does timezone math — it is the right call. The cost (~18–22KB gzipped) is unavoidable on those routes anyway, and deferring it only adds a loading state. The limitation is that for a mostly non-date app, you pay that cost on the marketing homepage, the login screen, and the settings page that never construct a ZonedDateTime.
Approach B: dynamic import (defer the cost)
Wrap the polyfill behind a dynamic import(). The bundler emits it as a separate chunk that is fetched only when the wrapper runs. This is the single highest-leverage change.
// src/temporal-loader.ts
type TemporalNamespace = typeof import('@js-temporal/polyfill').Temporal;
let cached: Promise<TemporalNamespace> | null = null;
export function getTemporal(): Promise<TemporalNamespace> {
// Memoize so the chunk downloads and evaluates at most once
if (cached) return cached;
cached = (async () => {
// Native-first: if the engine already ships Temporal, use it and fetch nothing
if (typeof globalThis.Temporal !== 'undefined') {
return globalThis.Temporal as TemporalNamespace;
}
// import() here is what tells the bundler to split into a lazy chunk
const mod = await import('@js-temporal/polyfill');
(globalThis as any).Temporal = mod.Temporal;
return mod.Temporal;
})();
return cached;
}
Now any feature that needs Temporal awaits the loader, and the chunk arrives on demand:
// In a date-heavy view/component
const Temporal = await getTemporal();
const zdt = Temporal.ZonedDateTime.from(
'2026-03-08T02:30:00[America/Denver]',
// Spring-forward gap: 02:30 does not exist. 'compatible' pushes forward to 03:30.
{ disambiguation: 'compatible' },
);
The trade-off is asynchrony: every Temporal call site is now behind a promise. Confine that to the boundary — load once near the route or feature entry, pass the resolved namespace inward — rather than awaiting in a hot loop.
Production implementation: a typed, native-aware loader
A robust loader handles three concerns: native detection, single-flight caching, and a synchronous accessor for code that runs after load. It should also never silently swallow a failed import.
// src/temporal.ts
type TemporalNS = typeof import('@js-temporal/polyfill').Temporal;
let loadPromise: Promise<TemporalNS> | null = null;
let resolved: TemporalNS | null = null;
/** Async: ensures Temporal is available, fetching the polyfill chunk only if needed. */
export function ensureTemporal(): Promise<TemporalNS> {
if (loadPromise) return loadPromise;
loadPromise = (async () => {
// Native engines (recent Chrome/Firefox) need zero bytes from us
if (typeof globalThis.Temporal !== 'undefined') {
resolved = globalThis.Temporal as TemporalNS;
return resolved;
}
try {
const mod = await import('@js-temporal/polyfill');
resolved = mod.Temporal;
(globalThis as any).Temporal = mod.Temporal;
return resolved;
} catch (err) {
loadPromise = null; // allow retry on transient chunk-load failure
throw new Error(`Failed to load Temporal polyfill: ${(err as Error).message}`);
}
})();
return loadPromise;
}
/** Sync accessor for code paths that run after ensureTemporal() has resolved. */
export function getTemporalSync(): TemporalNS {
if (!resolved) {
// Fail loud rather than producing `undefined.ZonedDateTime`
throw new Error('Temporal not loaded yet — await ensureTemporal() first.');
}
return resolved;
}
On the server (SSR/serverless), the calculus flips. There is no first-paint budget to protect, cold-start latency dominates, and an extra dynamic import only adds an async hop to every request. Import the polyfill eagerly server-side — or better, run on a Node version with native Temporal and skip it entirely. Keep the lazy path for the client bundle, where the download cost is what users feel. If you share a loader across both, branch on environment so the server takes the eager route.
Measuring it: bundle analyzers
Do not guess; measure before and after. The goal is to confirm the polyfill left the main chunk and now sits in its own lazy chunk.
# Vite / Rollup: generate a treemap of every chunk
npx vite-bundle-visualizer
# Webpack: emit stats and open the analyzer
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json
Read the output for three things: (1) the polyfill is not in your entry chunk, (2) it occupies a single named chunk roughly 18–22KB gzipped, and (3) that chunk is requested on a date route, not on first load. The Network panel's coverage and the chunk's "initial vs async" flag confirm the deferral worked. For the webpack-specific splitChunks configuration, see Tree-shake the Temporal polyfill in webpack.
Code-splitting date-heavy routes
If your date logic clusters in a few routes (a scheduling dashboard, an admin reports page), let the router's lazy boundary carry the polyfill for you. A lazily-imported route component that statically imports the loader will pull the polyfill chunk into the route's chunk graph automatically.
// router.ts (framework-agnostic shape)
const ScheduleView = () => import('./views/ScheduleView'); // lazy route
// ./views/ScheduleView.ts imports ensureTemporal at top-level;
// the bundler attaches the polyfill chunk to this route, not to main.js
The marketing homepage and login route never reference ScheduleView, so they never download the polyfill. This composes naturally with framework lazy-route APIs — you do not need a separate manual chunk if every Temporal consumer already lives behind a route boundary.
Edge cases
Native Temporal present, but partial
Some engines ship Temporal behind a flag or at an earlier spec revision. Feature-detecting only typeof globalThis.Temporal !== 'undefined' can accept an incomplete implementation. If you target bleeding-edge engines, probe a specific method (typeof globalThis.Temporal?.ZonedDateTime?.from === 'function') before trusting the native object, and fall back to the polyfill otherwise.
Server zone leaking into "native" path
When the native branch runs on the server, Temporal.Now.timeZoneId() returns the server's zone, not the user's. The bundle decision (native vs polyfill) is independent from the correctness decision (always pass an explicit IANA zone). Splitting the polyfill must not tempt you into dropping explicit timeZone arguments.
Chunk-load failure on flaky networks
A dynamically imported chunk can fail to download (offline, CDN hiccup, deploy mid-session with hashed filenames). Unlike an eager import baked into main.js, this is a runtime failure. The production loader above rethrows and clears its cache so a retry can succeed; surface a user-facing fallback rather than letting the date feature hang.
Gotchas & common pitfalls
- Splitting without deferring.
manualChunks/splitChunksalone only renames where the polyfill sits — if it is still statically imported by the entry, it remains an initial chunk. Anti-pattern fix: pair the split config with a dynamicimport(). - Awaiting the loader in a render loop. Calling
await ensureTemporal()per row or per frame serializes work behind the promise. Fix: load once at the feature boundary, pass the resolved namespace in. - Trusting
globalThis.Temporaltruthiness. A polyfilled global from a previous page or a partial native impl can pass a loose check. Fix: probe a concrete method, and memoize the result. - Dynamic-importing on the server for no reason. Lazy loading helps first paint, which servers don't have. Fix: branch on environment and import eagerly server-side.
- Expecting per-type tree-shaking. There is no smaller entry point for "just
PlainDate." Fix: optimize load timing, not import granularity.
Testing checklist
| Scenario | Input / action | Expected |
|---|---|---|
| Polyfill not in entry chunk | inspect analyzer treemap | @js-temporal/polyfill appears only in an async chunk |
| Lazy chunk fetched on date route | navigate to schedule view | network request for temporal.*.js fires on that route only |
| Native engine skips download | run in engine with native Temporal |
no polyfill chunk requested; getTemporal() resolves to global |
| Loader memoization | call ensureTemporal() 3× |
one network request; same namespace returned |
| Chunk-load failure recovery | block chunk URL, then unblock, retry | first call rejects; retry resolves |
| Sync accessor guard | call getTemporalSync() before load |
throws explicit "not loaded yet" error |
Run the date assertions across zones to confirm splitting did not change behavior:
# Same suite, multiple host zones — splitting must be behavior-neutral
TZ=UTC npm test
TZ=America/New_York npm test
TZ=Australia/Lord_Howe npm test # 30-minute-DST zone surfaces offset bugs
Frequently Asked Questions
Can I tree-shake the Temporal polyfill to import only PlainDate?
Not meaningfully. @js-temporal/polyfill exposes one Temporal namespace backed by a shared arithmetic/calendar core and a bundled IANA timezone database. Importing any single type pulls the shared core, and the tzdata cannot be split by zone because zones are looked up by runtime string. Optimize when it loads with a dynamic import(), not how much you import.
How big is the Temporal polyfill, really?
Roughly 18–22KB gzipped for the current @js-temporal/polyfill, dominated by the calendar engine and embedded timezone data. The exact number depends on your minifier and version — measure it in your own build with a bundle analyzer rather than trusting a headline figure.
Does manualChunks or splitChunks reduce the download?
No — those only control which chunk the code lands in. If the entry still statically imports the polyfill, it stays an initial chunk that loads before first paint. To actually defer the download you must dynamically import() it; the split config then names the resulting async chunk.
Should I lazy-load Temporal on the server too?
Usually not. Lazy loading protects browser first-paint, which servers don't have. On SSR/serverless, an extra dynamic import adds an async hop and worsens cold starts. Import eagerly server-side, or run on a Node version with native Temporal, and reserve the lazy path for the client bundle.
How do I avoid loading the polyfill in browsers that ship Temporal natively?
Feature-detect before importing: if globalThis.Temporal exists (probe a concrete method like Temporal.ZonedDateTime.from to be safe), use it and skip the dynamic import entirely. Only fall back to await import('@js-temporal/polyfill') when the native object is absent.