Skip to content

Performance · Concept

Code splitting with dynamic import() in a PWA

Published

In one line: Code splitting breaks a JavaScript bundle into pieces so the browser only downloads, parses, and executes what a page needs at startup; import() — a dynamic, promise-returning expression distinct from the static import statement — is the mechanism that loads the rest on demand.

import() is not a function call even though it looks like one — per MDN, “import itself is a keyword, not a function,” so it cannot be aliased like const myImport = import. It returns a promise that, on success, “fulfills to a module namespace object: an object containing all exports from moduleName.” Unlike a static import declaration, which “is static and will always result in the imported module being evaluated at load time,” a dynamic import() call can appear anywhere in code and load a module conditionally or on demand — for example, only after a user submits a form.

Web.dev frames the goal directly: “Code splitting is a technique that seeks to minimize startup time” by shipping less JavaScript upfront — “split your bundle into multiple pieces and only send what’s necessary at the very beginning.” Code that is dynamically imported “does not get included into the initial bundle and is now lazy loaded,” so it is fetched only when actually needed.

Smaller startup bundles mean less main-thread parse/compile/execution work, which “will contribute to better…INP times” by freeing the main thread to respond to input sooner. For client-side-rendered pages, trimming the JavaScript responsible for rendering markup can also improve LCP, particularly when the main thread is too busy to render the largest content element promptly.

Beyond manually deferring individual functions, web.dev points to higher-level strategies: “Splitting on the route or component level when using a client-side framework is a simpler approach to lazy loading different parts of your application,” and frameworks built on bundlers such as webpack often provide built-in abstractions for this. Third-party dependencies are usually handled differently — “split into a separate vendor bundle that can be cached since they don’t update as often” (for example with webpack’s SplitChunksPlugin) — rather than lazy-loaded on demand like route or component code. Bundlers that support dynamic import() for this purpose include webpack, Parcel, and Rollup.

import() “can be used in the main thread, a shared worker, or a dedicated worker, but will throw if called within a service worker or a worklet.” Plan splitting around code that runs on the main thread or in dedicated/shared workers — it is not available for lazy-loading logic inside a service worker.

Each normalized module specifier generally resolves to the same cached module namespace object — MDN notes this is “generally true,” with one documented exception: if the imported module exports a function named then, that function is invoked as part of the dynamic import’s promise resolution, which MDN warns against relying on. “This aggressive caching ensures that a piece of JavaScript code is never executed more than once, even if it is imported multiple times. Future imports don’t even result in HTTP requests or disk access.” That caching applies once a module has loaded and linked successfully; “only evaluation failures are cached,” so a module that fails to load or link may be retried on the next import.

  • Ship only what a route or view needs at startup; defer the rest behind import().
  • Split at the route or component boundary when using a client-side framework rather than hand-splitting individual functions.
  • Route third-party dependencies into a separate, long-cacheable vendor bundle instead of lazy-loading them per interaction.
  • Do not call dynamic import() inside a service worker or worklet — it throws there.
  • Preload chunks you know you’ll need soon so the split doesn’t trade startup cost for a visible delay later.

Specifications

SpecificationStatus
None.