ECMAScript - Introducing Deferred Module Evaluation with import defer | Nitay Neeman's Website<br>Skip to main content Every year, an edition of the ECMAScript Language Specification is released with the new proposals that are officially ready. In practical terms, the proposals are attached to the latest expected edition when they are accepted and reach stage 4 in the TC39 process:\n\n \n The stages of TC39 process\n \nOne of the more interesting stage 3 proposals is import defer (previously known as “Lazy Module Initialization”). Although the proposal is still a stage 3 candidate, its design is effectively complete and very close to being finalized toward stage 4.\nIn this article, we're going to examine this proposal and see how it's meant to work.\nMotivation\nA lot of the perceived slowness in large JavaScript applications isn't about downloading code - it's about running it. Once the bundle is downloaded, the JavaScript engine has to walk through every module's top level and execute the code that is there. This can burn a significant number of milliseconds before anything actually renders - especially for big apps.\nWe already have decent tools for the download side. Preloading avoids request waterfalls and Dynamic Imports lets us pull in a module only when we need it. But none of that touches execution cost: if a module runs expensive setup code the moment it's loaded, it's going to run that code no matter how cleverly we fetched it.\nNode’s CommonJS synchronous ecosystem already ran into this using require(): we can move it from the top of a file into a specific function without changing the function’s API, and the module will only be initialized when that function actually runs.\nFor instance, this: \n\n \n Copy\n const chartRenderer = require('chart-renderer');\n\nexports.renderReport = function (data) {\n return chartRenderer.render(data);\n};\n \n \nCan turn into:\n\n \n Copy\n exports.renderReport = function (data) {\n const chartRenderer = require('chart-renderer');\n return chartRenderer.render(data);\n};\n \n \nNothing changes for the functions that call renderReport - they still call the same function and get the same (synchronous) result back. The only difference is that chart-renderer doesn’t get loaded and initialized until someone actually renders a report.\nES modules don't have a good equivalent to do it without paying a price. Dynamic import can delay loading a module, but it forces us to transform the code into a Promise chain:\n\n \n Copy\n export async function renderReport(data) {\n const { render } = await import('chart-renderer');\n return render(data);\n}\n \n \nThis code creates two headaches:\n\nDynamic import doesn’t really improve things on the network side. By the time renderReport runs, we usually want chart-renderer to have already been downloaded and sitting in the cache. So import() ends up acting mostly as a way to delay execution, not as a way to reduce what we download.\nDynamic import makes renderReport now async - whether it needs to be or not. This means that every function that calls it should be async all the way up. That’s not a small refactor but rather an API change we have to align across the entire call chain.\n\nDynamic import gives us lazy loading, not lazy execution - it saves bytes on the network, but it can't keep a synchronous API while still delaying when a module's top-level code runs. \nThat's the gap import defer is aiming to close: eager loading with lazy execution and an API that stays synchronous the whole way through.\nThe Proposal\nThe proposal introduces a defer modifier to import that returns a deferred namespace object instead of running the module right away:\n\n \n Copy\n import defer * as ns from \"some-module\";\n \n \nWe still download the module and wire up all of its imports and exports right away. If some-module (or anything it depends on) fails to fetch or has a syntax error, we find out immediately, exactly as with a regular import. \nThe thing that is deferred with the new syntax is module evaluation.\nsome-module's top-level code doesn’t run until we actually read something from ns. That first read runs the whole module synchronously and after that ns behaves just like a normal namespace:\n\n \n Copy\n import defer * as ns from \"some-module\";\n\n// \"some-module\" has been downloaded and linked\n// but its top-level code has not run yet\n\nfunction useFeature() {\n return ns.feature(); // reading this is what triggers evaluation\n}\n \n \nSyntax\nimport defer only comes in the namespace form. This means there's no way to defer individual named imports like import defer { feature } from "some-module". We always get the whole namespace object (and never separate bindings).\nThere's also a dynamic version that mirrors import() :\n\n \n Copy\n const ns = await import.defer(\"some-module\");\n \n \nThis import.defer() call resolves once the module has been downloaded...