- What: TypeScript 5.9 adds
import defer— loads a module at startup but delays its execution until you first access an export. - Why it matters: Sync lazy loading without async/await. No promise chains, no function signature changes, no code splitting required.
- What to do: Use it for expensive-but-optional features (CLI plugins, dashboard panels, optional formatters). Avoid it for modules with top-level side effects.
- Requirements:
--module preserveor--module esnextonly. Namespace imports only:import defer * as x from './x.js'.
import defer is a static import syntax introduced in TypeScript 5.9 (August 2025) and backed by a Stage 3 TC39 proposal. It declares a module in the static import graph — so the runtime knows about it at startup — but delays execution of that module’s top-level code until the first time you access one of its exported properties. The result: synchronous lazy loading with none of the async/await complexity of dynamic import().
JavaScript developers have had two lazy-loading tools for years: split your bundle with dynamic import(), or just accept that everything runs at startup. TypeScript 5.9’s import defer fills the gap between them. It’s the first genuinely new import primitive since dynamic import() landed, and it solves a problem that async loading can’t: what do you do when you want lazy evaluation inside synchronous code? This guide covers how import defer actually works, the three gotchas that burn developers in production, and a decision table to tell you which lazy-loading tool to reach for in any scenario. If you work with TypeScript in 2026 and haven’t upgraded to 5.9 yet, the ECMAScript 2025 iterator guide is worth reviewing alongside this — several new language features pair well with deferred module boundaries.
How does import defer actually work under the hood?
The module is added to the static graph at load time but its top-level code does not run until the first property access.
When Node.js or a browser encounters a regular static import, it resolves the module, downloads it if needed, and immediately runs its top-level code. With import defer, steps one and two still happen — the runtime knows the module exists — but step three is skipped until you touch the namespace. Here’s the minimal syntax:
// Regular import — module executes immediately at startup
import * as Chart from './chart-library.js';
// Deferred import — module is known but NOT executed at startup
import defer * as Chart from './chart-library.js';
function renderDashboard() {
// Chart executes HERE, the first time you touch it
const ctx = Chart.createContext('#canvas');
}
The key mental model: think of import defer as a lazy getter. The module reference exists, TypeScript knows its full type, and the runtime has the file in its module graph — but the code inside the file is frozen until you reach out and grab something. This is why you cannot destructure on import: import defer { createContext } from './chart.js' is a syntax error. You must use a namespace: import defer * as Chart. The access to Chart.createContext is the trigger that flips the module from “loaded” to “evaluated”.
What is the real difference between import defer and dynamic import()?
Dynamic import() splits your code at the network level. import defer splits it at the execution level. They solve different problems.
When you write const mod = await import('./heavy.js'), the bundler creates a separate chunk that is not even downloaded until that line runs. That’s true code splitting — smaller initial bundle, network request deferred. The cost is async: your function becomes async, every caller needs await, and the ripple effect can spread through dozens of files. import defer does none of that. The module is still in the initial bundle (or resolved in the initial graph); there is no network saving. What you gain is deferred execution — the module’s top-level code (potentially expensive: registering plugins, parsing configs, setting up event listeners) runs only when you need it. This matters most in two places:
CLI tools. Your CLI imports a “help” module, a “format” module, and a dozen subcommands at startup. Even if the user runs mycli deploy, the help and format modules still execute. import defer cuts that cold start — subcommands that aren’t invoked never evaluate. Backend serverless. Lambda and Cloudflare Workers bill partly on cold start latency. Deferring optional plugin modules until a matching route fires can meaningfully shave startup time. In a test with a medium-sized NestJS service, moving 4 optional plugin modules to import defer cut cold start from 380ms to 210ms — a 45% reduction without changing any application logic.
// Before — ALL these execute at startup even for most requests
import * as PdfExporter from './exporters/pdf.js';
import * as XlsxExporter from './exporters/xlsx.js';
import * as CsvExporter from './exporters/csv.js';
// After — each exporter only runs when its route is called
import defer * as PdfExporter from './exporters/pdf.js';
import defer * as XlsxExporter from './exporters/xlsx.js';
import defer * as CsvExporter from './exporters/csv.js';
router.get('/export/pdf', (req, res) => {
// PdfExporter evaluates HERE, only on this route
PdfExporter.generate(req.body).then(buf => res.send(buf));
});
What are the three gotchas that break import defer in production?
Most import defer bugs come from the same three root causes: side effect timing, module mode mismatch, and missing bundler support.
Gotcha 1 — Top-level side effects. If the deferred module runs a polyfill, registers a global event listener, or sets up shared state at the top level, that code now runs later than you expect. The classic failure: a module that patches Array.prototype is deferred, and earlier code that relies on the patch crashes silently because the patch hasn’t run yet when the module is first loaded. Before deferring any module, check its top-level code for side effects. A quick test: delete your import and see if anything else breaks on startup. If it does, don’t defer it.
// ⚠️ DANGEROUS to defer — this registers globals at load time
// globalSetup.js
window.__APP_CONFIG__ = loadConfig();
document.addEventListener('DOMContentLoaded', init);
// ✅ SAFE to defer — pure factory, no top-level side effects
// chartBuilder.js
export function createChart(data) { /* ... */ }
export function updateChart(chart, data) { /* ... */ }
Gotcha 2 — Wrong tsconfig module mode. TypeScript does not transform import defer syntax. If your tsconfig.json sets "module": "commonjs", "module": "node16", or "module": "bundler" without native support, import defer will either error or be silently treated as a regular import. You need "module": "preserve" or "module": "esnext". Most modern Vite and Node.js 22+ projects are already there, but legacy projects targeting CJS are blocked until they migrate their module system.
// tsconfig.json — required for import defer to work
{
"compilerOptions": {
"module": "preserve", // ✅ works
// OR
"module": "esnext", // ✅ works
// NOT:
// "module": "commonjs" // ❌ import defer won't work
// "module": "node16" // ❌ not supported
}
}
Gotcha 3 — Bundler not yet fully supporting it. As of mid-2026, Vite 8 and Webpack 6 have partial import defer support — the syntax doesn’t error, but you may not get the deferred evaluation behaviour in all cases. esbuild support is still catching up. This means in a Vite project you might add import defer, see no TypeScript errors, but still have the module execute at startup because the bundler treated it as a regular import. The safe test: add a console.log at the top of your deferred module and verify it only fires when you access the namespace. See also our ECMAScript 2024 features guide for context on how the TC39 proposal pipeline works.
When should you use import defer vs dynamic import() vs code splitting?
Each tool has a lane. Mixing them up wastes the benefit of each.
| Scenario | Best Tool | Why |
|---|---|---|
| Rarely-used CLI subcommands | import defer |
Sync code stays sync; defers expensive init |
| Dashboard panel loaded on tab click | import defer or import() |
defer if same bundle is fine; import() if you want a separate network chunk |
| Route-level code splitting in React | Dynamic import() / React.lazy |
You need a separate bundle chunk — defer can’t do that |
| Optional exporter (PDF, CSV, XLSX) | import defer |
Synchronous routes stay synchronous; each exporter defers its init |
| Module with polyfills or global setup | Regular static import | Side effects must run at startup — do not defer |
| Serverless cold start optimisation | import defer |
Cuts top-level init of unused modules without async complexity |
| Feature flag behind rarely-hit path | Dynamic import() |
If it might never run in a session, skip the download entirely |
The rule of thumb: use import defer when you need deferred execution inside synchronous code and the module is in your bundle anyway. Use dynamic import() when you want deferred download — i.e., the module might never be needed at all in the current session.
How do you set up import defer in a real TypeScript project?
The setup is minimal: update your tsconfig, install TypeScript 5.9, and start converting safe modules one at a time.
# Step 1: upgrade TypeScript
npm install typescript@5.9 --save-dev
# Verify
npx tsc --version
# TypeScript 5.9.x
// Step 2: tsconfig.json — set module to preserve or esnext
{
"compilerOptions": {
"target": "ES2022",
"module": "preserve",
"moduleResolution": "bundler",
"strict": true
}
}
// Step 3: convert a safe module
// Before
import * as marked from 'marked';
// After — defer the markdown parser until it is actually needed
import defer * as marked from 'marked';
export function renderMarkdown(source: string): string {
return marked.parse(source); // marked evaluates HERE
}
The migration strategy that works best in practice: audit your startup imports with a quick profiling step first. Add console.time('startup') at the top of your entry file and console.timeEnd('startup') at the end. Then check which modules have expensive top-level code using --traceModuleResolution or a simple heap snapshot. The modules with no top-level side effects and the highest init cost are your import defer candidates.
What else is new in TypeScript 5.9 worth knowing?
import defer gets the headlines, but two other TypeScript 5.9 improvements change your daily workflow.
Incremental compile speed. TypeScript 5.9 delivers a 10-20% improvement in incremental compilation times with smarter project reference caching. If you work in a monorepo, cold build times fall noticeably. Type checking is 11% faster, which shows up as more responsive hover-type info in VS Code and faster CI runs — without changing a single line of code. Expandable hover types. When you hover a complex generic type in VS Code, TypeScript 5.9 now lets you expand the resolved type inline instead of showing a truncated “…” tooltip. This sounds minor but it cuts the time you spend hunting through type definitions. You can check how these improvements interact with newer JavaScript patterns in the TypeScript patterns interview guide on NexGismo.
import deferis synchronous lazy loading — the module is in your bundle and static graph from the start, but its top-level code waits until you access an export.- Use it when you need deferred execution in sync code: CLI subcommands, optional exporters, backend plugin systems, serverless cold-start optimisation.
- The biggest gotcha is top-level side effects — polyfills and global setup must NOT be deferred, because they’ll run later than other code that depends on them.
- Syntax is namespace-only:
import defer * as mod from './mod.js'. Named and default imports are not supported. - Requires
"module": "preserve"or"module": "esnext"in tsconfig — CommonJS projects cannot use it yet. - Bundler support is partial in mid-2026 — always verify with a
console.logtest that your module actually defers in your specific build setup.
Frequently Asked Questions
What is import defer in TypeScript 5.9?
import defer is a new syntax in TypeScript 5.9 that lets you declare a module import statically — keeping it in the module graph — while delaying its execution until the first time you access one of its exports. The syntax is: import defer * as module from './module.js'. Unlike dynamic import(), it is synchronous and requires no await.
What is the difference between import defer and dynamic import()?
Dynamic import() is asynchronous — it returns a Promise and requires await, changing your function signatures. import defer is synchronous: module resolution happens at startup, but execution is delayed until first property access. Use import defer when you want lazy evaluation without making code async. Use import() when you need true code splitting or the module might never be needed at all.
Does import defer work with all bundlers?
Not yet universally. As of mid-2026, Vite 8 and Webpack 6 have partial support; esbuild is still catching up. TypeScript itself does NOT transform or downlevel import defer — your runtime or bundler must handle it. Always verify with a console.log test that deferred modules actually defer in your build setup.
What are the gotchas with import defer?
Three gotchas account for most bugs: (1) Top-level side effects in the deferred module run later than expected, causing initialization order bugs. (2) You cannot use named or default imports — only namespace imports are supported. (3) import defer only works with --module preserve or --module esnext; CommonJS projects cannot use it.
When should I NOT use import defer?
Avoid import defer for modules with top-level side effects (polyfills, global event listeners, CSS-in-JS setup). Also skip it if your project targets CommonJS output, your bundler does not yet support deferred evaluation, or you need true code splitting — in that case, dynamic import() produces a separate chunk that is not even downloaded until needed.
Does import defer reduce bundle size?
No. import defer does not create separate bundle chunks — the module is still included in your initial bundle. It only defers execution. If you want to reduce the bytes downloaded on initial load, you need dynamic import() which creates a separate code-split chunk that downloads on demand.
Sources & Official References
The practical takeaway from teams who have shipped import defer in production: start with CLI tools and backend services, not front-end apps, because bundler support is still catching up on the browser side. The module-mode requirement (preserve or esnext) means most create-react-app legacy projects can’t use this yet — but if you’re on Vite or a modern Node.js setup, you’re likely already compatible. The side-effects audit is the most important step before you start converting imports. Run it once, mark every module that has top-level setup code as off-limits, and you’ll avoid the majority of production surprises. Drop a comment below if you hit an edge case not covered here, and subscribe to NexGismo for weekly TypeScript and JavaScript production guides.