Tree-Shake the Temporal Polyfill in Webpack

To minimize @js-temporal/polyfill impact in a webpack build, dynamically import() it into its own chunk, name that chunk with a splitChunks cache group, and feature-detect native Temporal so modern browsers download nothing. Part of Temporal Polyfill Bundle Size & Tree-Shaking.

Why this scenario is tricky

The intuitive move — import { Temporal } from '@js-temporal/polyfill' at the top of a module — defeats every optimization webpack offers. The polyfill is a single Temporal namespace whose calendar engine, arithmetic, and bundled IANA timezone database are all interdependent, so webpack's tree-shaking cannot drop "unused" pieces; importing one type drags the whole ~18–22KB-gzipped unit in. Worse, because the import is static, webpack hoists it into whatever chunk reaches it first, which for an entry-level import is main.js. The polyfill then downloads before first paint on every page, including routes that never construct a date.

The second trap is assuming splitChunks fixes this. It does not, on its own. A cache group only changes which chunk holds the module; if the module is still statically required by the entry graph, webpack keeps it as an initial chunk that loads up front. The deferral comes from the import() expression — that is the only thing that tells webpack to emit an async chunk fetched on demand. splitChunks then just gives that chunk a stable, cacheable name. You need both, in that order.

Minimal working solution

The shortest correct setup is one dynamic import behind a memoized wrapper, plus a one-line magic comment to name the chunk.

// temporal.ts — minimal
type TemporalNS = typeof import('@js-temporal/polyfill').Temporal;

let cached: Promise<TemporalNS> | null = null;

export function loadTemporal(): Promise<TemporalNS> {
  // Memoize: the chunk must download and evaluate at most once
  if (cached) return cached;
  // Native-first: a browser that ships Temporal needs zero polyfill bytes
  if (typeof globalThis.Temporal !== 'undefined') {
    cached = Promise.resolve(globalThis.Temporal as TemporalNS);
    return cached;
  }
  // webpackChunkName names the emitted async chunk; import() makes it lazy
  cached = import(
    /* webpackChunkName: "temporal-polyfill" */ '@js-temporal/polyfill'
  ).then((m) => m.Temporal);
  return cached;
}
// usage at a feature boundary
const Temporal = await loadTemporal();
const zdt = Temporal.ZonedDateTime.from(
  '2026-03-08T02:30:00[America/Denver]',
  // 02:30 is inside the spring-forward gap; 'compatible' moves forward to 03:30
  { disambiguation: 'compatible' },
);

That alone removes the polyfill from main.js. The splitChunks config below makes the chunk boundary explicit and stable across builds.

Full production version

A production wrapper adds robust native detection, retry on chunk-load failure, and a synchronous accessor for code that runs after the load resolves.

// temporal.ts — production
type TemporalNS = typeof import('@js-temporal/polyfill').Temporal;

let loadPromise: Promise<TemporalNS> | null = null;
let resolved: TemporalNS | null = null;

function hasNativeTemporal(): boolean {
  const t = (globalThis as { Temporal?: unknown }).Temporal as
    | { ZonedDateTime?: { from?: unknown } }
    | undefined;
  // Probe a concrete method, not just typeof — guards against partial/flagged impls
  return typeof t?.ZonedDateTime?.from === 'function';
}

export function ensureTemporal(): Promise<TemporalNS> {
  if (loadPromise) return loadPromise;

  loadPromise = (async () => {
    if (hasNativeTemporal()) {
      resolved = (globalThis as any).Temporal as TemporalNS;
      return resolved;
    }
    try {
      const mod = await import(
        /* webpackChunkName: "temporal-polyfill" */ '@js-temporal/polyfill'
      );
      resolved = mod.Temporal;
      (globalThis as any).Temporal = mod.Temporal; // expose for legacy global readers
      return resolved;
    } catch (err) {
      loadPromise = null; // clear cache so a transient chunk failure can be retried
      throw new Error(`Temporal polyfill chunk failed to load: ${(err as Error).message}`);
    }
  })();

  return loadPromise;
}

export function getTemporalSync(): TemporalNS {
  if (!resolved) {
    // Fail loud instead of returning undefined and crashing deeper in date code
    throw new Error('Temporal not loaded — await ensureTemporal() first.');
  }
  return resolved;
}

The webpack config keeps the entry lean and pins the async chunk into its own cache group so its hash and name stay stable for long-term caching.

// webpack.config.js — minimal, focused on isolating the polyfill chunk
const path = require('path');

module.exports = {
  mode: 'production',
  entry: './src/index.ts',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash].js',
    // Async chunks (incl. the dynamic Temporal import) get hashed, cacheable names
    chunkFilename: '[name].[contenthash].js',
    clean: true,
  },
  resolve: { extensions: ['.ts', '.js'] },
  module: {
    rules: [{ test: /\.ts$/, use: 'ts-loader', exclude: /node_modules/ }],
  },
  optimization: {
    usedExports: true, // enable tree-shaking for YOUR code (polyfill stays whole)
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        temporal: {
          // Match the polyfill package and give it a deterministic chunk name
          test: /[\\/]node_modules[\\/]@js-temporal[\\/]polyfill[\\/]/,
          name: 'temporal-polyfill',
          chunks: 'async', // only split it out of async graphs — keeps it lazy
          priority: 30,
          reuseExistingChunk: true,
        },
      },
    },
  },
};

The load-bearing line is chunks: 'async'. With chunks: 'all' on this cache group, webpack would be free to promote the polyfill into an initial chunk if any synchronous path touched it, quietly undoing the deferral. Scoping the group to async chunks guarantees it only ever ships as an on-demand download.

Verification snippet

Prove the behavior with a small assertion suite plus a build inspection.

// temporal.test.ts
import { ensureTemporal, getTemporalSync } from './temporal';

test('memoizes and resolves to a working Temporal', async () => {
  const a = await ensureTemporal();
  const b = await ensureTemporal();
  console.assert(a === b, 'ensureTemporal must return the same namespace each call');

  const sync = getTemporalSync(); // safe now that the load resolved
  const feb29 = sync.PlainDate.from({ year: 2024, month: 2, day: 29 });
  // 2024 is a leap year, so Feb 29 must round-trip exactly
  console.assert(feb29.toString() === '2024-02-29', 'leap day must be valid');
});

test('sync accessor fails loud before load', () => {
  // In a fresh module state this throws rather than returning undefined
  // expect(() => getTemporalSync()).toThrow(/not loaded/);
});
# Confirm the polyfill is in its own ASYNC chunk, not in main.js
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json
# In the treemap: temporal-polyfill.[hash].js exists and main.[hash].js does NOT contain it

Common pitfalls

// Wrong — static import, polyfill ends up in main.js
import { Temporal } from '@js-temporal/polyfill';

// Right — dynamic import emits a lazy chunk
const { Temporal } = await import(
  /* webpackChunkName: "temporal-polyfill" */ '@js-temporal/polyfill'
);
// Wrong: temporal: { chunks: 'all', ... }   // may become an initial chunk
// Right: temporal: { chunks: 'async', ... } // stays an on-demand chunk
// Wrong: if (typeof globalThis.Temporal !== 'undefined') { /* trust it */ }
// Right: if (typeof globalThis.Temporal?.ZonedDateTime?.from === 'function') { /* ... */ }

Frequently Asked Questions

Why doesn't webpack tree-shake the unused parts of the Temporal polyfill?

Because there are no separable parts to drop. @js-temporal/polyfill is one Temporal namespace whose calendar logic, arithmetic, and bundled IANA timezone database are interdependent, and the timezone data is queried by runtime string, so webpack cannot statically prove any zone is dead. Tree-shaking only removes provably unused exports; here the right lever is deferring the whole chunk with import(), not shrinking it.

Do I need both the dynamic import and the splitChunks cache group?

Yes, and the order of responsibility matters. The dynamic import() is what makes webpack emit an async, on-demand chunk — that is the actual deferral. The splitChunks cache group then gives that chunk a stable, content-hashed name for long-term caching. The cache group alone, without a dynamic import, leaves the polyfill as an initial chunk.

How do I confirm the polyfill really left the main bundle?

Build with --profile --json and open the output in webpack-bundle-analyzer. You should see a temporal-polyfill.[hash].js chunk that is async (requested on a date route) and a main.[hash].js that does not contain @js-temporal/polyfill. The Network panel should show the polyfill chunk loading only when a date feature runs.