I once worked on a frontend app where total loading time went from 15 seconds to 1.1 seconds.
The strange part was that the app looked optimized. It already had lazy loading, preloading, and caching. On paper, the usual boxes were checked. But in practice, it still loaded too much code upfront, preloading managed to make loading slower, and cache kept dying each time we did a new deployment.
The tools were there. The loading architecture was not.
So I stopped treating loading as a bundler setting and started treating it as part of the app architecture: what must load now, what can wait, what can start earlier, what should stay independent, and how to keep cache useful for longer.
Stop Loading Unused Code
The first step is obvious, but still important: use lazy loading.
The impact can be large. In a large production app I worked on, the generated static files were more than 22 MB in total. Before any lazy loading, opening any page required the browser to download code for the whole application. However, after applying it properly, the initially downloaded static files dropped to about 0.5 MB, while individual pages loaded around 1.1–3.5 MB.
That said, React.lazy is just a tool. The harder part is knowing where to use it. Often, even when it is used, a lot of code is still loaded too early, simply because developers don't load lazily everything that they could.
Usually, there are 2 levels to lazy loading. The most important thing is to load application pages lazily. Solely by doing that, there is a good chance you will reduce loading size drastically. However, I have a question for you.
❓ If the user never opens a modal window, should they download its code?
The second level of lazy loading is what most developers forget. Lazy loading is not about pages. It's about all code that is not used initially. Good examples would be: hidden UI components, rare or conditional JavaScript logic, or sometimes even entire NPM packages.
Just a single modal window, a side panel, or a piece of logic, may seem small. But they add up. In my case, such 2nd-level "beyond-pages" optimization accounted for about 20% of the final initial-loading improvement.
So, you should use lazy loading, but don't treat it like a tool for pages only:
📌 The application should load code only when there is a reason.
Don't Centralize What Should Load Separately
Many developers treat lazy loading as just dynamic imports. But dynamic imports are only the visible part. The way you structure static imports matters just as much. Because even if you can use React.lazy and split pages into separate chunks, you could still make the browser download code you expected to keep separate.
The mistake: shared code inside a page's file
This is one of my favourite lazy-loading bugs, because the code looks completely innocent.
// App.tsx
const Page1 = React.lazy(() => import('./pages/Page1'));
const Page2 = React.lazy(() => import('./pages/Page2'));
// Page1.tsx
import { HeavyChart } from './HeavyChart';
import { LargeTable } from './LargeTable';
export const CommonComponent = () => <div>Common component</div>;
export default function Page1() {
return (
<>
<CommonComponent />
<HeavyChart />
<LargeTable />
</>
);
}
// Page2.tsx
import { CommonComponent } from './Page1';
import { Page2Content } from './Page2Content';
export default function Page2() {
return (
<>
<CommonComponent />
<Page2Content />
</>
);
}
Both pages are lazy-loaded, but Page2 imports CommonComponent from Page1. That alone can make the browser pull the code for HeavyChart, LargeTable, and other Page1 dependencies, every time we open Page 2. Even though we just wanted to use a single one-line component.
Why this happens
For bundlers such as Webpack, JavaScript modules are file-based. If you import something from a file, you are not importing an isolated line of code. You are connecting to that module and its static dependencies. So if CommonComponent is used by multiple pages, it should not live inside Page1.tsx and must be moved into a separate file.
The "convenient import" trap
The problem is not limited to pages. Centralization of logic is what we should fear:
- one file exports many unrelated components;
- one file contains helpers used by different lazy pages;
- one "facade" file imports many modules and exposes one convenient API;
- one "registry" file imports every possible component;
- or even every time we do a barrel import from a package.
📌 The main rule to keep your application structure healthy is too decentralize your logic is much as possible. The best approach performance-wise would be to make each file to contain only 1 export. However, considering DX, we should at least aim to to keep shared logic (components, helpers, constants, and utilities) live in separate files.
This topic is quite large. You can refer to the Part 2 of my original series for the deeper examples behind this problem, including what "dependency graphs" are and why "barrel imports" and Java-style patterns are bad for your performance.
Make Your Cache Survive Deployments
Lazy loading improves the first visit. Cache improves the next ones.
And this part is easy to underestimate. You can split the app perfectly, load only the right pages, reduce the initial download, and still make users download too much when they open the application again. We should aim to keep cache useful for as long as possible.
When a new version of the app is built, generated files may receive new names. If names change unnecessarily, the browser treats those files as new and downloads them again. So we should keep file names the same as long as their content stays the same.
Use content hashes
In Webpack, that usually means using content-based hashes:
output: {
filename: '[name].[contenthash].js',
chunkFilename: '[name].[contenthash].js',
}
📌 Using
contenthashis the most reasonable strategy to organize your file names. **All other existing file name templates may make unnecessary file name updates.
Keep chunk dependencies clean
Cache is not only about filename configuration.
Even when we use contenthash, a single one-line change might cause multiple file to be renamed. Because generated chunks are dependent on each other. And when one chunk renames, all of its dependencies might be renamed too. When it happens, multiple files lose cache.
The good thing is that we already started to care about "static imports" and application file structure. Because with a clean file structure, such renamings will happen less often.
ℹ️ Keeping logic decentralized helps unrelated chunks stay independent, which makes cache more useful.
Split vendors for cache, not just loading
Vendors are another part of the same problem.
There are multiple strategies to organize vendor loading. Let's regard the simplest one: putting all NPM packages into a single vendor file. It might look convenient, but there are some problems:
- Such file is very fragile. Every time we start to use a new NPM package or even a new member from an existing one, such a file will be renamed and cache will be lost.
- If all the vendors are in a single file, they will be downloaded initially. Even those vendors, that could be loaded lazily. That increases initial loading time.
Most of vendor-splitting strategies have this problem. The better approach is to make it selective:
- Cache groups should be created only for initially loaded files;
- Cache groups should be created only for stable, broadly reused libraries.
For example, React-related packages are usually good candidates for a stable cache group. Because you must download 'react' entirely regardless of your imports, and you should do it initially. But packages like lodash-es or date-fns are the opposite, because they could be loaded any time and their content depends on what you import from them.
With the Webpack config below, React-related packages are grouped into one initial vendor chunk, while other NPM packages are not forced into that chunk and can still be split by actual usage.
module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
react: {
filename: `react.[contenthash:8].js`,
// It's better to create groups for initial vendors only
chunks: 'initial',
// Will include `react`, `react-dom`, and `react-router-dom` in a single chunk
test: /react/,
},
},
},
},
};
I go deeper into this mechanism, with examples of how many files lose cache before and after cleaning the dependency graph, in Part 3 of the original series.
Don't Make Users Wait Twice
Lazy loading improves initial loading time, but all it does is moving the wait to later. When the user opens a lazy page, they will still wait for its JavaScript. They will also wait again for data from server. And we could reduce these delays too by loading files and data early. We should only predict when to do that.
Use HTML prefetch and preload
One way to load files earlier is with HTML hints:
<link rel="prefetch" href="/some-future-file.js" />
<link rel="preload" href="/important-file.js" as="script" />
📌 Use HTML's
prefetchto preload your lazy files.
Using prefetch and preload is very easy and efficient, albeit not as simple as many think.
For starters, we shouldn't prefetch the whole application blindly. UX-critical lazy files, such as common pages, are better candidates for prefetching.
However, the most important part is the underlying understanding of the difference between prefetch and preload. Using preload may sound like it's just a better way to optimize your application. But in reality,
ℹ️ Misusing preload may worsen your application performance.
So, unless you are 100% certain in your entire application loading architecture and you have a complete understanding of the difference, you should not using preload. And me, even when I do have both, I still often decide to use only prefetch.
Use user actions as loading signals
There are more ways to predict what to load next. Users often give signals before they perform an action. And we could base our loading decisions on these signals.
By signals, I mean when users:
- hover a link or a button;
- scroll close to a certain block;
- or follow certain user flow, when we are sure that a certain component will be opened next.
For example, a page should be loaded lazily. And when a user hovers a link to switch to this page, we can start loading that page files before the user actually clicks on the link. And this logic is applicable for other lazy components too: buttons that open modal windows or buttons that trigger rare logic.
📌 Preload static files manually based on user interactions.
Load data earlier too
Files are not the only thing users wait for. Even if JavaScript loads quickly, the screen can still stay empty while it waits for API data. And usually, when we open a lazy-loaded page directly, we see the following pattern:
- The browser loads initial files;
- The browser loads page files;
- The browser starts requesting data from the server.
This pattern can be improved. If we already know that the user is likely to open a page or another lazy component, we can start requesting its data earlier. For example, page data could be requested directly after initial files are loaded and together with page files. That can save us a few hundred milliseconds additionally.
📌 Preload data from server together with lazy JavaScript, not after.
Applying such loading techniques can drastically improve your loading time. In my case, that strategy alone saved about half of the time I talked about in the beginning (15s --> 1.1s). If you are interested in prefetch/preload trade-offs, how to organize static-file preloading, or a simple data-preloading example, you can explore the Part 4 of my original series.
Conclusion
Lazy loading is useful, but it is not enough by itself.
If we want an application to load fast, we need to control the whole loading flow: what code is loaded immediately, what can wait, what should be prepared in advance, and what should stay cached between deployments.
I hoped you enjoyed this article. You might've noticed that it was a compressed version of a longer in-depth technical series. If you want the deeper examples and experiments, here is the full version:
- Part 1 — Why lazy loading is important
- Part 2 — Dependency graphs
- Part 3 — Vendors and cache
- Part 4 — Preload in advance
Thanks for reading.
Top comments (4)
What usually gets missed is that lazy loading alone doesn’t fix everything. If your critical images or main bundle are still heavy, you just shift the delay instead of removing it.
Did you end up finding that images or JS bundles were the bigger contributor in your case?
My application was a complex micro-FE app. There wasn't a single main reason. There were hundreds of downloaded JS files, linear loading in stages, huge response sizes in API, cache that didn't work, and so on. But not images, my app barely has any
Lazy loading is essentially loading content without the visitor immediately noticing it. So, theoretically, it is just loading with additional steps. I don’t think it’s inherently bad or anything; in fact, it’s required strictly for a smooth website. However, when the website ends up with a bunch of content that’s lazy-loaded forever, it’s not helpful.
One crucial aspect that has a significant impact but is often completely overlooked is ensuring that your website supports HTTP/3. Any other approach is not capable of utilizing parallelization effectively.
You can handle lazy loading effectively even when the whole app consists of lazy parts. And I'd say you should aim for making it as lazy as possible. When there's a reason to it, of course