Post

Beyond the UI: Understanding Modern Frontend Engineering · Part 12

New to this series? Start with Part 1

Code Splitting and Lazy Loading Explained: Shipping Less JavaScript

A modern web application can be fast on a developer’s machine and painfully slow on a real user’s device. Locally, everything appears almost instantly: navigation is smooth, interactions respond quickly, and the browser seems to handle everything effortlessly. In production, someone opening the same application on a mid-range smartphone over a slower mobile connection may wait several seconds before it becomes interactive. Even after the loading indicator disappears, clicking a button can feel delayed because the browser is still processing JavaScript for features the user may never open.

The problem is not necessarily a slow API, an inefficient database, or poor network infrastructure. Sometimes, we are simply shipping too much JavaScript too early. Modern frontend applications often contain hundreds of components alongside charting libraries, editors, authentication flows, dashboards, and administrative tools. Yet a user opening the login page does not need the code responsible for rendering financial charts, and someone reading an article does not need a rich-text editor loaded into memory.

Code splitting and lazy loading address this problem by dividing the application into smaller pieces and loading them when they become necessary. The idea is straightforward, but the engineering decisions require care: where should we split the application, when should a chunk be loaded, and what happens when a network request fails? Can splitting too aggressively make performance worse? To answer these questions, we first need to examine what happens when the browser receives JavaScript.


1. The Hidden Cost of JavaScript

When we talk about JavaScript performance, we often focus on execution time. However, before JavaScript can execute, the browser must perform several other operations. A simplified lifecycle looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Download JavaScript
        |
        v
Decompress
        |
        v
Parse
        |
        v
Compile
        |
        v
Execute
        |
        v
Initialize Application
        |
        v
Handle User Interactions

Each stage consumes resources. Downloading a large bundle takes time, especially on slower networks. Parsing and compiling that bundle consume CPU resources. Executing the code can block the browser’s main thread, delaying rendering and user interactions. Even if a dependency is never used during the initial page view, including it in an eagerly loaded bundle may still impose download and processing costs. Consider a dashboard application containing:

FeatureApproximate JavaScript
Authentication and application shell80 KB
Dashboard components120 KB
Charting library180 KB
Rich-text editor250 KB
PDF generation utilities160 KB
Administrative tools140 KB
Total930 KB

These numbers are illustrative, but the architectural problem is common. If the entire application is bundled together, someone visiting the login page could be forced to download JavaScript associated with charts, PDF generation, and administrative tools. And remember, 930 KB of transferred or compressed JavaScript is not necessarily equivalent to 930 KB of processing cost. The browser may need to parse and compile a substantially larger amount of source code after decompression. JavaScript has a network cost, a processing cost, and a memory cost. Code splitting attempts to reduce the amount of that work required at any given moment.

2. What Is Code Splitting?

Code splitting is the process of dividing an application’s JavaScript into separate bundles, commonly called chunks, that can be loaded independently. Instead of generating one large application bundle:

1
2
3
4
5
6
7
Application
    |
    v
app.js (930 KB)
    |
    v
Browser downloads everything

We might produce:

1
2
3
4
5
6
7
8
9
10
11
12
13
Application
    |
    +-- core.js
    |
    +-- dashboard.js
    |
    +-- charts.js
    |
    +-- editor.js
    |
    +-- pdf-export.js
    |
    +-- admin.js

The browser initially downloads only the code required to start the application and display the current page. Other chunks are requested later. For example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
User opens login page
        |
        v
Load core.js
        |
        v
User logs in
        |
        v
Load dashboard.js
        |
        v
User opens analytics
        |
        v
Load charts.js

This changes the application’s loading model. Rather than asking users to pay the entire JavaScript cost upfront, we distribute that cost across the user journey. Code splitting does not automatically reduce the total amount of JavaScript in an application. It changes when JavaScript is downloaded and processed. That distinction matters because deferring work can improve initial performance without eliminating the work itself.

3. Code Splitting vs Lazy Loading

The two concepts are closely related, but they are not identical. Code splitting determines how code is divided into independently loadable chunks. Lazy loading determines when those chunks are requested and initialized. In a library containing 1,000 books, code splitting would be equivalent to organizing the books into separate shelves, while lazy loading would mean bringing a particular book to the reader when they need it.

You can split code without loading it lazily. For example, an application might generate multiple chunks but eagerly download all of them during startup. Likewise, lazy loading requires some mechanism for deferring resources, but those resources do not have to be JavaScript. Images, videos, and embedded content can also be lazy-loaded. For this article, our focus is JavaScript. The relationship is:

1
2
3
4
5
6
7
8
9
10
Code Splitting
      |
      v
Create independently loadable chunks
      |
      v
Lazy Loading
      |
      v
Request chunks when needed

Both techniques become most effective when they are designed around actual user behavior.

4. How JavaScript Bundlers Create Chunks

Modern frontend development commonly involves a bundler or build tool. Tools such as Webpack, Rollup, Vite, and other build systems analyze the application’s module dependency graph. Consider:

1
2
3
4
5
6
7
// main.js
import { initializeApp } from "./app.js";
import { renderDashboard } from "./dashboard.js";
import { generatePDF } from "./pdf.js";

initializeApp();
renderDashboard();

Each static import establishes a dependency that the bundler can analyze. Conceptually, the dependency graph might look like:

1
2
3
4
5
6
7
8
9
10
11
main.js
  |
  +-- app.js
  |
  +-- dashboard.js
  |      |
  |      +-- charts.js
  |
  +-- pdf.js
         |
         +-- pdf-library.js

The bundler decides how to organize these modules into output files. Depending on its configuration, it may produce one bundle or several chunks. However, simply having multiple output files does not guarantee that loading has been deferred. If the application needs all those files before startup, users may still download and process everything immediately. To create a genuinely deferred dependency, we need to change how the application imports it.

5. Static Imports vs Dynamic Imports

JavaScript provides two important mechanisms for loading modules.

Static imports

A static import declares a dependency directly:

1
import { generateReport } from "./report.js";

The module is part of the statically discoverable dependency graph. Bundlers can include it in the initial loading path or in an eagerly loaded shared chunk.

Dynamic imports

A dynamic import allows a module to be loaded at runtime:

1
2
3
const reportModule = await import("./report.js");

reportModule.generateReport();

The import() expression returns a Promise that resolves to the module namespace. This gives the application control over when the module is requested. For example:

1
2
3
4
5
async function handleExportClick() {
  const { generateReport } = await import("./report.js");

  await generateReport();
}

Now the report-generation module does not need to be loaded when the user first opens the application. It can be requested when the user clicks Export. The conceptual flow becomes:

1
2
3
4
5
6
7
8
9
10
11
12
13
Initial Page Load
        |
        v
Load application essentials
        |
        v
User clicks Export
        |
        v
Download report module
        |
        v
Execute report generation

In a production build, the bundler can use this dynamic import as a chunk boundary. There is one important qualification: the exact output depends on the build system, configuration, and target environment. Dynamic imports create opportunities for splitting, but developers should inspect the generated bundles rather than assume a particular result.

6. Route-Based Code Splitting

One of the most natural places to split an application is at route boundaries. A user viewing the homepage does not necessarily need the code for the settings page, analytics dashboard, or administrative console. Consider:

1
2
3
4
5
6
7
8
9
10
11
Application
    |
    +-- /home
    |
    +-- /dashboard
    |
    +-- /analytics
    |
    +-- /settings
    |
    +-- /admin

Without route-based splitting, these routes might all contribute to the initial JavaScript payload. With route-based splitting, each route can have its own chunk.

1
2
3
4
5
6
7
8
9
Initial Load
    |
    +-- Application Shell
    |
    +-- Current Route

Later Navigation
    |
    +-- Load Destination Route

A simplified framework-agnostic example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const routeLoaders = {
  "/home": () => import("./pages/home.js"),
  "/dashboard": () => import("./pages/dashboard.js"),
  "/analytics": () => import("./pages/analytics.js"),
  "/settings": () => import("./pages/settings.js"),
};

async function loadRoute(path) {
  const loader = routeLoaders[path];

  if (!loader) {
    throw new Error("Route not found");
  }

  const module = await loader();

  return module.render();
}

Each route is loaded only when navigation requires it. Framework routers often provide built-in mechanisms for implementing this pattern, although their APIs and loading behavior differ. Route-based splitting is attractive because routes usually represent meaningful functional boundaries. It is also easier to reason about than splitting individual utility functions throughout the application.

A single route can still contain large features that are rarely used, so route-based splitting alone may not be sufficient. Component-level splitting lets us defer those features within the route.

7. Component-Level Lazy Loading

Imagine an analytics dashboard with several sections:

1
2
3
4
5
6
7
8
9
10
11
Analytics Dashboard
    |
    +-- Summary Cards
    |
    +-- Recent Transactions
    |
    +-- Advanced Charts
    |
    +-- Export to PDF
    |
    +-- Historical Comparison

The summary cards and recent transactions might be essential to the initial experience. Advanced charts and historical comparisons may be hidden behind tabs or expandable sections. Instead of loading everything immediately, we can defer these features. For example:

1
2
3
4
5
6
7
async function openHistoricalComparison() {
  const { renderComparison } = await import(
    "./features/historical-comparison.js"
  );

  renderComparison();
}

This technique is particularly useful for:

  • Large charting and visualization libraries.
  • Rich-text and code editors.
  • Maps and geospatial features.
  • PDF generation and document processing.
  • Complex configuration panels.
  • Features accessible only to certain user roles.

If a feature is not required for the initial experience, question whether its code belongs in the initial loading path. This does not mean every component should be lazy-loaded. Small, frequently used components can be cheaper to include in the initial bundle than to fetch separately.

8. The Danger of Splitting Too Aggressively

Code splitting is an optimization, but excessive splitting can introduce new performance problems. Suppose an application divides its JavaScript into 100 tiny chunks. Each chunk might be small, but the browser now needs to coordinate many resource requests and module dependencies. HTTP/2 and HTTP/3 make multiple requests more efficient than older connection models, but requests still carry overhead, and dependency chains can delay execution. Consider:

1
2
3
4
5
6
7
8
9
10
11
12
13
Load dashboard.js
        |
        v
Discover widgets.js
        |
        v
Discover chart-core.js
        |
        v
Discover chart-utils.js
        |
        v
Finally render chart

This is a loading waterfall. The application cannot request certain dependencies until earlier modules have been downloaded and evaluated or their dependency relationships have been discovered. Bundlers and browsers can mitigate some waterfalls through dependency analysis and preloading, but poor chunk organization can still create unnecessary delays. The goal is to create useful loading boundaries, with each chunk representing work that makes sense to load together. A practical strategy is to start with larger functional boundaries, such as routes and substantial optional features, and split further only when measurements justify it.

9. Understanding Loading Waterfalls

A loading waterfall occurs when one resource must be loaded before the browser can discover or request another resource. For example:

1
2
3
4
5
6
7
8
9
10
11
12
13
HTML
 |
 v
main.js
 |
 v
dashboard.js
 |
 v
charts.js
 |
 v
chart-data.js

If each dependency is discovered sequentially, the total loading time can become significantly longer than necessary. This is especially noticeable when network latency is high. Imagine each dependent request introduces 100 milliseconds of waiting before the next resource can be requested. Four sequential dependencies can create substantial delay even when the files themselves are relatively small.

One way to address this is to make important dependencies discoverable earlier. Another is to preload or prefetch resources when there is a strong reason to believe they will be needed. However, these strategies must be applied carefully. Preloading everything defeats the purpose of lazy loading.

10. Preloading vs Prefetching

Preloading and prefetching are related resource-loading hints, but they serve different purposes.

Preloading

Preloading communicates that a resource is important for the current page and should be fetched early. For example:

1
2
3
4
<link
  rel="modulepreload"
  href="/assets/dashboard.js"
/>

Module preloading can help the browser fetch an important module before the normal module loading path would otherwise discover it. This can reduce loading waterfalls.

Prefetching

Prefetching communicates that a resource may be useful for future navigation or interaction.

1
2
3
4
<link
  rel="prefetch"
  href="/assets/analytics.js"
/>

The browser may fetch this resource opportunistically, depending on network conditions, priorities, and browser support. The distinction is:

TechniquePrimary purpose
PreloadFetch a resource needed for the current experience
PrefetchPrepare a resource that may be needed later
Lazy loadingDefer loading until a trigger or need arises

An application might preload the dashboard route after successful authentication and prefetch the analytics route when the user shows intent to navigate there. The important question is not whether a resource can be loaded early. It is whether loading it early creates more value than the bandwidth and processing resources it consumes.

11. Intent-Based Prefetching

One useful strategy is to load code shortly before the user is likely to need it. For example, a navigation menu might prefetch a route when the user hovers over its link.

1
2
3
4
5
6
7
8
9
10
let analyticsPromise;

function preloadAnalytics() {
  analyticsPromise ??= import("./pages/analytics.js");

  return analyticsPromise;
}

analyticsLink.addEventListener("pointerenter", preloadAnalytics);
analyticsLink.addEventListener("focus", preloadAnalytics);

When the user eventually navigates to the analytics page, the module may already be available. This can make navigation feel almost instantaneous. However, hover-based prefetching is not always reliable. Touchscreen users do not necessarily hover. Some users navigate with keyboards. Others may move their pointer across multiple links without intending to open them.

A more robust strategy combines intent signals with actual navigation patterns. For example, the application might prefetch a commonly visited route after the current page becomes idle. Prefetching still consumes bandwidth, however, and speculative downloads on a constrained mobile connection can compete with resources the user actually needs. A good prefetching strategy should consider user intent, device conditions, and the cost of being wrong.

12. Lazy Loading and User Experience

From an engineering perspective, lazy loading can reduce initial JavaScript. From a user’s perspective, it introduces a new question: What happens while the feature is loading? Imagine a user clicks an Analytics tab. The application starts downloading the charting module, but nothing appears to happen for two seconds. Even though the initial page was faster, the interaction now feels broken. Lazy loading should therefore be designed together with the user experience. A useful loading sequence is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
User clicks Analytics
        |
        v
Show immediate feedback
        |
        v
Load analytics module
        |
        v
Initialize chart components
        |
        v
Fetch or reuse data
        |
        v
Render analytics

The interface might display a skeleton, a lightweight placeholder, or a loading indicator. The important part is that the user receives immediate feedback. For a small optional feature, a spinner may be sufficient. For a major route transition, a layout-preserving skeleton can reduce perceived disruption. However, loading states should not be used to disguise consistently poor performance. If a frequently used feature takes several seconds to initialize on every visit, the loading strategy deserves reconsideration.

13. Avoiding Layout Shifts During Lazy Loading

Lazy loading can also affect visual stability. Imagine a page that initially renders:

1
2
3
Header
Summary Cards
Footer

Then a large chart loads and inserts itself between the summary cards and footer. The footer suddenly moves downward. If the user was about to click something near the bottom of the page, the unexpected movement can be frustrating. A better approach is to reserve space for content that will appear later.

1
2
3
<div class="chart-placeholder">
  Loading chart...
</div>
1
2
3
.chart-placeholder {
  min-height: 320px;
}

When the chart becomes available, it replaces the placeholder without causing a major layout shift. The reserved dimensions should reflect the expected content rather than being arbitrary. This is particularly important for images, embedded content, and large dynamically loaded components. Lazy loading should improve performance without making the interface visually unstable.

14. Handling Chunk Loading Failures

Dynamic imports are asynchronous operations that can fail when a user’s connection drops, a CDN becomes unavailable, or a browser extension interferes with a request. Applications deployed frequently also face a failure mode involving outdated chunk references. Imagine this sequence:

  1. A user opens the application.
  2. The browser loads the current application shell.
  3. A new version is deployed.
  4. The old chunk files are removed from the server.
  5. The user navigates to a route that was not previously loaded.
  6. The application requests a chunk belonging to the old deployment.
  7. The request fails because the file no longer exists.

The user may encounter a blank page or a chunk loading error. A basic error-handling pattern looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
async function openReports() {
  try {
    const module = await import("./pages/reports.js");

    module.render();
  } catch (error) {
    console.error("Unable to load reports:", error);

    showErrorMessage(
      "Reports could not be loaded. Please try again."
    );
  }
}

But production applications need more than a generic error message. Possible recovery strategies include retrying transient failures, offering a refresh option when an outdated deployment is detected, and retaining previous hashed assets for a period after deployment. Be careful with automatic page reloads. If the underlying problem persists, an unconditional reload can create an infinite refresh loop. Chunk loading failures are a reminder that code splitting is not purely a build-time concern. It affects deployment strategy and runtime reliability.

15. Versioned Assets and Deployment Safety

Modern build systems commonly generate filenames containing content hashes. For example:

1
2
3
app.2f9a1c.js
dashboard.7b4e21.js
charts.a83d90.js

When a file’s contents change, its filename changes. This enables long-lived caching because the filename identifies a particular version of the asset. However, it also means an older application instance may continue referencing older filenames. A robust deployment strategy often looks like:

1
2
3
4
5
6
7
8
9
Deploy Version 2
        |
        +-- Publish new assets
        |
        +-- Keep old hashed assets available
        |
        +-- Update HTML / entry point
        |
        +-- Retire old assets after a safe window

This is especially relevant for single-page applications where users may leave tabs open for hours or days. If deployments immediately delete every asset from the previous version, lazy-loaded routes become vulnerable to failures. An alternative or complementary strategy is to detect when the application version has changed and prompt the user to refresh. The right solution depends on hosting architecture, release frequency, and how long users typically keep sessions open.

16. Shared Dependencies and Chunk Duplication

Another challenge appears when multiple lazy-loaded features depend on the same library. Imagine:

1
2
3
4
5
6
7
8
9
10
11
Analytics Route
    |
    +-- chart-library

Reports Route
    |
    +-- chart-library

Admin Route
    |
    +-- chart-library

A poorly configured build could duplicate parts of the same dependency across several chunks. This increases the total amount of JavaScript users download as they navigate through the application. Bundlers often attempt to extract shared dependencies into common chunks. For example:

1
2
3
4
5
analytics.js -----+
                  |
reports.js -------+----> chart-vendor.js
                  |
admin.js ---------+

Now the shared charting dependency can be downloaded once and reused. However, extracting every shared dependency into a common chunk is not automatically optimal. If a shared chunk becomes enormous, users visiting a small feature may be forced to download code primarily needed elsewhere. Chunk organization is a balancing act between reuse and isolation. The best structure depends on how frequently different routes are visited together.

17. Tree Shaking Is Not Code Splitting

Tree shaking and code splitting are often discussed together, but they solve different problems. Tree shaking attempts to remove unused code from the production output. Code splitting divides the remaining code into independently loadable pieces. Suppose a utility library exports ten functions, but your application uses only two.

1
import { formatDate } from "./utilities.js";

If the module structure and build conditions allow it, tree shaking may remove unused exports from the generated bundle. Code splitting, on the other hand, might place the entire date-formatting feature into a separate chunk that is loaded only when needed. The difference:

1
2
3
4
5
Tree Shaking:
Remove code that is not needed.

Code Splitting:
Defer code that is needed later.

An effective build can use both. First, eliminate code that should never ship. Then, organize the remaining code so users receive it when they need it. Neither technique substitutes for the other.

18. Dynamic Imports and Third-Party Libraries

Third-party dependencies are often excellent candidates for lazy loading. Consider a CSV export feature.

1
2
3
4
5
6
7
import { convertToCSV } from "./csv-utils.js";

function exportData(rows) {
  const csv = convertToCSV(rows);

  downloadFile(csv);
}

If the export functionality is rarely used, the application may not need the conversion code during startup. We can defer it:

1
2
3
4
5
6
7
async function exportData(rows) {
  const { convertToCSV } = await import("./csv-utils.js");

  const csv = convertToCSV(rows);

  downloadFile(csv);
}

This is particularly valuable when the imported module brings a large dependency graph. Inspect what actually ends up in the chunk before deciding where to split it. A seemingly small helper function may import a substantial general-purpose library. Likewise, a large library might support modular imports that allow the bundler to include only a small subset. Before introducing a lazy-loading boundary, ask two questions:

  1. Is this code expensive enough to justify deferring?
  2. Is it used infrequently enough that deferring it will benefit most users?

Both questions matter. A large module used immediately by nearly every user may be better optimized through dependency reduction rather than lazy loading.

19. Lazy Loading and Server-Side Rendering

Server-side rendering introduces another dimension to code splitting. With server-side rendering, the server generates HTML for the initial page. The browser can display meaningful content before all client-side JavaScript has loaded. However, the JavaScript required to hydrate interactive components may still be substantial. Consider:

1
2
3
4
5
6
7
8
9
10
11
12
13
Server
  |
  v
Render HTML
  |
  v
Browser displays content
  |
  v
Download JavaScript
  |
  v
Hydrate interactive components

Code splitting can reduce the JavaScript required for the initial route. But splitting must be coordinated with hydration. If a component’s JavaScript is deferred, the application needs a strategy for when that component becomes interactive. Some rendering architectures support selective, partial, or island-based hydration, allowing independent sections to become interactive at different times. Others rely on route-level hydration and lazy-loaded components within a larger application.

The important distinction is that server-rendered HTML and client-side interactivity are separate concerns. A page can appear fully rendered while still waiting for JavaScript needed to handle interactions. Reducing initial JavaScript can help, but the rendering architecture determines how those improvements translate into user experience.

20. Measuring Whether Code Splitting Actually Helps

Performance optimization should be guided by measurements rather than assumptions. After implementing code splitting, developers should compare the application before and after the change. Useful metrics include:

MetricWhat it helps reveal
Initial JavaScript transferredWhether the first page requires less JavaScript
JavaScript parse and execution timeWhether main-thread processing has decreased
Largest Contentful Paint (LCP)When the largest visible content element renders
Interaction to Next Paint (INP)How responsive the application is to user interactions
Total Blocking Time (TBT)How much long-task blocking occurs during a lab measurement
Route transition latencyHow quickly lazy-loaded destinations become usable
Chunk request failuresWhether deferred loading is reliable in production

It is also useful to inspect the bundle composition. A bundle analyzer can reveal which modules are included in each chunk and whether dependencies are duplicated. Browser developer tools can show resource waterfalls and main-thread activity.

A successful optimization might reduce initial JavaScript significantly while keeping navigation latency acceptable. However, suppose the initial load improves by 300 milliseconds, yet every navigation to a commonly used route now incurs a 700-millisecond delay. Whether that is an improvement depends on actual user journeys. Performance is not just about how quickly the first screen appears. It is about how the application behaves throughout the session.

21. A Practical Example: An Analytics Application

Let’s bring these concepts together using a realistic application. Imagine a financial analytics platform with the following features:

1
2
3
4
5
6
7
8
9
10
11
12
13
Financial Analytics Platform
    |
    +-- Login
    |
    +-- Dashboard
    |
    +-- Transactions
    |
    +-- Advanced Analytics
    |
    +-- PDF Reports
    |
    +-- Administration

Nearly every authenticated user visits the dashboard, while advanced analytics is used occasionally and PDF reports are generated less frequently. Administration is available only to a subset of users, giving us another clear boundary for separating code. A reasonable loading strategy could be:

FeatureLoading strategyReason
LoginInitial route chunkRequired immediately
Application shellEagerShared across authenticated routes
DashboardRoute-based splitNeeded after authentication
TransactionsRoute-based splitLoaded on navigation
Advanced analyticsRoute and feature splitHeavy, less frequently used
PDF reportsDynamic import on exportExpensive and infrequent
AdministrationSeparate route chunkRestricted, not needed by most users

The loading flow might look like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
User opens application
        |
        v
Load login + essential runtime
        |
        v
User authenticates
        |
        v
Load dashboard route
        |
        +--> Prefetch likely next route
        |
        v
User opens analytics
        |
        v
Load analytics chunk
        |
        v
Render dashboard and charts
        |
        v
User clicks Export PDF
        |
        v
Load PDF generation module
        |
        v
Generate report

This architecture avoids forcing every user to pay for every feature upfront. But it also introduces responsibilities. The application needs loading states, error recovery, sensible prefetching, deployment-safe assets, and monitoring for chunk failures. Code splitting works best when those concerns are part of the design from the beginning.

22. Common Mistakes When Implementing Code Splitting

Several mistakes appear repeatedly in frontend applications.

Splitting every component

Small components do not automatically deserve separate chunks. Excessive splitting can increase complexity and introduce unnecessary loading delays.

Lazy-loading critical content

If the main content of the page is always needed immediately, deferring it may worsen the experience. Lazy loading is most valuable when the deferred work is genuinely optional or not immediately required.

Ignoring loading states

A deferred feature without visible feedback can make the interface appear unresponsive.

Prefetching everything

Aggressive prefetching can consume bandwidth and compete with important resources. It may recreate the very problem code splitting was intended to solve.

Deleting old assets immediately after deployment can break open sessions that still reference previous chunk filenames.

Assuming smaller bundles guarantee better performance

Bundle size is only one factor. Execution cost, network latency, device capability, route transitions, and dependency structure also matter.

Failing to inspect generated output

A dynamic import in source code does not tell the entire story. Always inspect the production build to confirm that the intended chunks were created and loaded as expected.

23. How to Decide What Should Be Lazy-Loaded

Deciding whether to defer a feature comes down to its cost, frequency, and timing. Cost includes the work required to download, parse, compile, and initialize the feature. Frequency describes how often users need it, especially during the initial experience, while timing captures how soon after opening the application they are likely to use it. Together, these dimensions help guide the loading strategy:

Feature characteristicsLikely strategy
Small and required immediatelyEager loading
Large and required immediatelyOptimize dependency size and critical loading path
Large and rarely usedLazy loading
Large and likely to be used soonLazy loading with selective prefetching
Restricted to certain rolesRoute or feature-based splitting
Used on every routeConsider a shared eagerly loaded chunk

There is no universal chunk size or splitting threshold that works for every application. The right decision depends on user behavior, network conditions, device capabilities, and the application’s architecture.

Bringing It All Together

Code splitting and lazy loading change how we think about delivering frontend applications. Instead of treating the entire application as one large package that every user must download, we organize code around meaningful boundaries and actual user needs. The resulting architecture might look like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Application Build
       |
       v
Analyze Module Dependencies
       |
       v
Create Useful Chunk Boundaries
       |
       +-- Critical Application Code
       |
       +-- Route-Specific Code
       |
       +-- Optional Feature Code
       |
       +-- Shared Dependencies
       |
       v
Browser Loads Critical Code
       |
       v
User Interacts
       |
       +-- Navigate to Route
       |        |
       |        v
       |   Load Route Chunk
       |
       +-- Open Optional Feature
       |        |
       |        v
       |   Load Feature Chunk
       |
       +-- Likely Future Navigation
                |
                v
          Selective Prefetch

But the implementation is only part of the work. Good code splitting also requires thoughtful loading experiences, failure recovery, cache-aware deployments, and performance measurement. Without those considerations, we may simply exchange one performance problem for another.

Final Thoughts

Modern frontend performance depends on both writing efficient JavaScript and deciding how much JavaScript the browser should receive, and when it should receive it. Code splitting divides applications into independently loadable pieces, while lazy loading defers those pieces until they are needed. Together, they can improve initial load times, reduce unnecessary processing, and make large applications more accessible on slower devices and networks.

The objective is to ensure that users do not pay the performance cost of features they are not using, while keeping the features they do use responsive and reliable. That requires meaningful loading boundaries, clear feedback during loading, and measurements that reflect the whole user journey. The most effective frontend architectures deliver the right code at the right time, because sometimes the biggest performance improvement comes from not loading unnecessary JavaScript in the first place.


What’s Next?

The next article in Beyond the UI: Understanding Modern Frontend Engineering is Part 13: Web Accessibility Explained: Building Interfaces Everyone Can Use. We’ll explore how browsers expose accessibility information and how semantic HTML, keyboard navigation, focus management, screen readers, and ARIA help make interfaces usable for people with different abilities and interaction needs. These practices take us beyond how an interface looks to whether everyone can use it effectively.

This post is licensed under CC BY 4.0 by the author.