How import() Changed Frontend Architecture and (almost) Nobody Noticed
JS Edition
We spent years arguing about code splitting. Webpack configs, bundle strategies, route-based splitting, component-level splitting; entire articles written about how to slice your JavaScript so users don’t download what they don’t need.
And then import() landed, and we quietly got everything we wanted. Most developers use it every day without realizing what actually changed.
This isn’t about the syntax. It’s about what the syntax made possible.
Before dynamic import, the module graph was static
When you write this at the top of a file:
import { something } from './module';That’s a static import. The bundler sees it at build time, traces the entire dependency graph, and produces a bundle. Everything is decided before your app runs. The user downloads what the bundler decided they need, upfront, whether they’ll ever use it or not.
This was fine when apps were simpler. It became a serious problem when SPAs started containing entire product suites in a single JavaScript payload.
The workarounds were ugly. You’d manually define entry points. You’d split by route. You’d use webpack’s require.ensure() which was a non-standard hack that predated any real spec. Nothing felt right because nothing was right. You were fighting the static nature of the module system itself.
import() is a function that returns a Promise
That one sentence is the whole thing.
const module = await import('./heavy-chart-library');You can call this anywhere. Inside an event handler. After a user interaction. Based on a feature flag. On a timer. Conditionally, based on what the user actually does.
button.addEventListener('click', async () => {
const { renderChart } = await import('./chart');
renderChart(data);
});The browser only fetches that module when the user clicks the button. Not before. Not speculatively. When it’s actually needed.
This is lazy loading as a first-class language feature, not a bundler trick.
What this quietly replaced
Route-based code splitting in every major framework — React’s lazy(), Vue’s async components, Angular’s loadChildren — is all just import() underneath. The frameworks didn’t invent lazy loading. They wrapped import() in something ergonomic.
// React.lazy is just this
const HeavyPage = React.lazy(() => import('./HeavyPage'));The reason this became the default pattern for routing isn’t that React invented it. It’s that import() made it trivially expressible, and bundlers (Vite, webpack, Rollup) all learned to detect import() calls and automatically split those modules into separate chunks.
Write import() and you get a separate chunk. No config. No magic comments required (though they exist for naming). The boundary is wherever you put the dynamic import.
The architectural shift nobody narrated
Here’s what actually changed: the module graph became a runtime decision, not a build-time decision.
Before import(), the shape of your application’s JavaScript was determined entirely at build time. After it, the shape is determined by user behavior. You ship a skeleton, and the rest of the app loads based on what the user actually does.
This is why micro frontends became viable. Not because the concept was new; it wasn’t; but because import() gave you a clean mechanism to load remote modules at runtime:
const remoteModule = await import('https://other-app.example.com/module.js');Module Federation in webpack 5 is built on this. The whole “load a separately deployed chunk from another team’s app” pattern only works cleanly because import() is dynamic and Promise-based. Without it, you’re back to script tags and globals.
The part most developers skip
import() returns a module namespace object, not a default export. This trips people up:
// This won't work the way you expect
const MyComponent = await import('./Component');
// You need either
const { default: MyComponent } = await import('./Component');
// Or
const module = await import('./Component');
const MyComponent = module.default;Named exports work cleanly though:
const { formatDate, parseDate } = await import('./dates');Also worth knowing: browsers cache module fetches. Call import('./module') ten times and the network request only happens once. The Promise resolves from cache every subsequent time. You don’t need to manually deduplicate.
Where this is going
Import maps are now supported in all modern browsers. They let you control module specifiers without a bundler:
<script type="importmap">
{
"imports": {
"lodash": "https://cdn.jsdelivr.net/npm/lodash-es/lodash.js"
}
}
</script>Then anywhere in your code:
const { debounce } = await import('lodash');No bundler. No node_modules. Just a browser resolving a named import to a URL at runtime.
We’re slowly moving toward a world where the bundler is optional for many use cases, and import() is a big part of why. The module system was always supposed to be native. We’re finally getting there.
import() didn’t just add a feature. It changed when JavaScript gets loaded, who decides that, and what’s even possible in frontend architecture. Most developers treat it as “the thing React.lazy uses.” That’s underselling it by a lot.


