I knew something was wrong the moment I loaded my Next.js app in the browser. The initial page took far too long to become interactive. As a developer, that made my eye twitch. After some digging, I discovered the culprit: an avalanche of JavaScript being sent over the network on first load. This is the story of how I diagnosed and fixed a tangled web of imports that was bloating my bundle, cutting 500KB–1MB of JavaScript from the initial load and drastically improving performance.
The Mystery of the Slow Page Load
My journey began with a simple observation: the first page load was sluggish. Navigations after that were fine (thanks to client-side routing), but that initial load felt like wading through mud. Users hitting our site for the first time were paying a heavy cost in load time. I fired up Chrome DevTools and looked at the Network panel. Sure enough, over a megabyte of JavaScript (uncompressed) was being transferred on that first page. That’s a lot of script for just loading a page UI.
Why does this matter so much? Well, JavaScript is expensive for browsers to handle. Unlike an image that the browser can just display after downloading, JS has to be parsed, compiled, and executed — an intensive process. In fact, byte for byte, JavaScript can be more taxing than images or CSS. So sending 500KB or 1MB of extra JavaScript (even if it’s compressed to maybe ~200KB over the wire) can significantly slow down how quickly the page becomes usable. I had to trim this fat.
Digging Deeper with Bundle Analysis
First step in solving this: figure out what exactly is in that huge JavaScript bundle. I suspected we were shipping code that wasn’t needed upfront, or maybe some library was pulling in the kitchen sink. To get a closer look, I turned to the Webpack Bundle Analyzer. Next.js makes it easy to use via the @next/bundle-analyzer plugin, which generates an interactive treemap of your bundles. This tool is amazing – it shows each file or module and how much space it takes in the bundle, so you can literally see the bloat.
After enabling the bundle analyzer and building the app, I opened the report. It was immediately clear something was off. One chunk stood out as gigantic – much larger than anything else. Inside it, I could see pieces of code that looked familiar (names of our internal modules) combined with things that definitely did not belong in the client bundle (Node-specific libraries, polyfills, etc.). This was a huge red flag that some server-side code was being bundled into the client-side JavaScript by mistake.
The image above is an example of a Webpack Bundle Analyzer treemap (from another project) highlighting a single library (lottie.js in this case) dominating the bundle size. In my app’s report, a similar huge rectangle represented our internal API module pulling in a lot of code, confirming that something was dragging way more code into the client bundle than it should. Identifying these large chunks visually was a crucial first step in diagnosing the issue.
But knowing which chunk is large is only half the battle. I needed to understand why it was so large. What was pulling in these server-side bits? Time to put on the detective hat and trace some dependencies.
Mapping the Dependency Spaghetti
To untangle this mystery, I reached for another tool: dependency-cruiser. This nifty utility can analyze your project and spit out a dependency graph – basically a map of which files import which other files. By visualizing the import graph, I hoped to find the path through which our client-side code was inadvertently dragging in heavy server-side modules.
Running dependency-cruiser on the codebase (with Graphviz to generate an SVG diagram) produced a sprawling map of our project. It looked intimidating at first (picture a bowl of spaghetti noodles – that’s a typical large app’s dependency graph!). But I zeroed in on the part of the graph around that suspicious large module from the bundle report. There it was: an “API” module that had connections to both client-side pages and server-side utilities. Aha!
In our case, we had a file in our code (let’s call it api.js in the services layer) that was being imported by a Next.js page for client-side use, but inside api.js there were also imports of Node-only libraries and server-side code. Essentially, we had mixed together client and server concerns in one module. This made Webpack include everything from that module in both the client and server bundles. No wonder the client bundle was enormous!
This kind of entanglement confused Webpack’s tree shaking. Normally, a bundler like Webpack can “shake out” unused code (a.k.a tree shaking, which is an optimization to drop dead code). But here, the code in api.js that was meant for the server wasn’t clearly unused – it was just not needed on the client. Webpack couldn’t easily tell which parts of api.js were safe to omit for the browser. In fact, according to a Next.js discussion, Webpack wasn’t sure if the server exports had side effects, so it kept them in the client bundle. In other words, our tangled imports prevented effective tree shaking.
I also learned that Next.js has some built-in smarts to drop code that’s exclusively used in server-only functions (like getServerSideProps). But that only works if you keep your imports clean. In one case, we had an import that we intended only for server use, but because we imported it at the top of a page file and didn’t actually use it inside getServerSideProps, Next.js “thought” it might be needed on the client too – and so it bundled it there. Little oversights like that were costing us precious kilobytes.
Tangled Imports in Action (and the Fix)
Let me illustrate the core issue with a simplified example. Imagine we have a service module that looks like this:
// Before: Combined module (client + server mixed together)
// file: services/api.js
import AWS from 'aws-sdk'; // Node-only, heavy library (~MBs)
import fetch from 'node-fetch'; // another server-side import
export async function fetchDataClient(id) {
// Client-side logic (e.g., call a public API or another endpoint)
return fetch(`/api/data?id=${id}`).then(res => res.json());
}
export async function fetchDataServer(id) {
// Server-side logic using AWS SDK (should not run in browser!)
const dynamo = new AWS.DynamoDB();
return await dynamo.getItem({ Key: { id: { S: String(id) } }, /* ... */ }).promise();
}
In the above “Before” scenario, we have an api.js that exports two functions: one meant for client use, one for server. The client-side code (e.g., a Next page or component) might do import { fetchDataClient } from 'services/api' and use it. Even if fetchDataServer is never called on the client, by importing the entire api.js module, all of it gets bundled. That means the heavy aws-sdk (which is large) and node-fetch polyfill are now part of the client bundle, even though the browser will never actually execute fetchDataServer. Webpack can’t safely tree-shake it out because it doesn’t know if fetchDataServer might be used or have side effects. This is exactly what happened to us.
The fix? We refactored and decomposed that module at the “service” level, separating the client and server parts into different files. For example:
// After: Separate modules for client and server logic
// file: services/api-client.js
export async function fetchDataClient(id) {
// (same implementation as before, no Node-specific imports here)
return fetch(`/api/data?id=${id}`).then(res => res.json());
}
// file: services/api-server.js
import AWS from 'aws-sdk';
export async function fetchDataServer(id) {
// (same implementation as before, Node-only code lives here)
const dynamo = new AWS.DynamoDB();
return await dynamo.getItem({ Key: { id: { S: String(id) } } }).promise();
}
// file: pages/index.js (Next.js page component or data fetching)
import { fetchDataClient } from '@/services/api-client';
export default function HomePage(props) {
// ... uses fetchDataClient for some client-side operation if needed
}
// If we need server-side data fetching on this page:
export async function getServerSideProps(context) {
const id = context.params.id;
// import server logic dynamically or within this function to avoid bundling it in client
const { fetchDataServer } = await import('@/services/api-server');
const data = await fetchDataServer(id);
return { props: { data } };
}
By splitting api.js into api-client.js and api-server.js, we ensure that client-side code only imports the client module, which has no heavy dependencies, and the server-side code can import the server module when needed (even dynamically, as shown above, to keep it out of the initial bundle). This clear separation immediately allowed Webpack/Next to exclude the AWS library from the client bundle entirely. The suggestion from the Next.js team was precisely this: mark server-only code as side-effect free or move it into separate files. We chose to separate files, which made the boundaries crystal clear.
We also went through other instances of cross-contamination. In a few places, we had utility files that did double duty (similar to the above). We refactored those so that anything that runs on the server (database calls, file system access, etc.) lived in files that are only imported in server-side contexts (like API routes or within getServerSideProps). Conversely, our components and client-side code would only import lightweight client utilities.
Another tweak was ensuring we don't have any unused imports that could confuse Next’s bundler. For example, if you import a module in a page but never use it in getServerSideProps or the component, Next might not realize it's meant to be server-only and will include it on the client. So we cleaned those up or moved those imports inside the server-only functions. By doing an import inside getServerSideProps, you signal to Next that this code is purely server side, and Next will strip it from the client bundle. This is a neat trick for cases where you can’t easily separate a file — just import it on the fly in the server function.
Throughout this refactoring, I constantly rebuilt and checked the bundle analyzer to see the impact. It was like watching a weight-loss progress chart for my app. Every unnecessary import I eliminated or isolated made the bundles a bit leaner. 🏋️
Results: Lighter, Faster, More Efficient
After all these changes, the difference was dramatic. Our initial JS payload shrank by ~500KB to 1MB (depending on the page). That’s half a megabyte (or more) less JavaScript that the browser has to download, parse, and execute on first load. In terms of real user impact, the initial load felt snappier. Time-to-interactive dropped noticeably (hundreds of milliseconds saved, at least, and more on slower networks/devices). We no longer see a long "spin" before the page responds. The latency improvement was especially apparent on mobile devices where CPU and network are slower – exactly where that fat bundle hurt the most.
Qualitatively, it felt like we went from a clunky, heavy app to a slim, responsive one. And all of this without removing any feature – we simply loaded the right code in the right place.
Another bonus: our build times improved a bit too, since there was less code to process in the client bundle and fewer gigantic chunks to optimize. It’s a win-win.
Beyond the specific numbers, the biggest outcome was confidence: now I know our app is delivering only the code it needs to, and nothing more, to the user’s browser. No secret passenger sneaking onto the client-side express train.
Lessons Learned
This debugging journey taught me a few important lessons that are worth sharing with any Next.js (or generally, React/webpack) developer facing performance issues:
- Analyze your bundles regularly. Tools like Webpack Bundle Analyzer provide invaluable insight into whatyou’re shipping. If something is huge, you’ll spot it there first. Don’t fly blind when it comes to bundle size – measure it!
- Visualize dependencies. When things get tangled, a dependency graph tool like dependency-cruiser can save hours of guessing. It helped me see the import relationships clearly, making the “aha!” moment possible when I spotted the client-server import knot.
- Keep server-side code out of the client bundle. It sounds obvious, but in a Next.js project it’s easy to accidentally import something in the wrong place. Remember that anything you import at the top of a component or page might end up in the client bundle. If it’s meant for server (e.g. database libs, Node-specific APIs), isolate it. Use dynamic imports or restrict it to server-only files. Next.js will tree-shake server-only imports if and only ifyou structure your code to make that possible.
- Tree shaking isn’t magic. It has limitations. Webpack can only tree-shake unused code if it can statically analyze that the code is safe to drop. If you mix concerns in one file or use patterns it can’t analyze, you might end up with dead code retained. Be mindful of things like side effects and combined modules. In our case, splitting one module into two was the key – in another case, you might need to mark exports with /*#
PURE */ comments or set "sideEffects": false in package.json for your own packages to help the bundler out. - Eliminate unused imports and code. This is good practice in general, but it has performance implications. An unused import in a Next.js page can still cost you if the bundler can’t be sure it’s unused on the client. Lint your project for unused variables/imports, and remove them. It keeps your codebase cleaner and your bundles leaner.
- Leverage dynamic imports for heavy optional stuff. We didn’t have a lot of truly optional features in this app, but if you have a chunk of code or library that isn’t needed for initial render, consider loading it on demand. Next.js supports dynamic import() which will create a separate chunk that loads only when you use it. This can be great for admin-only pages, large charts, rich text editors, etc., where the user might not need that code upfront. (Just don’t overdo it or you could introduce latency when that code is eventually needed.)
- Test performance improvements with real metrics. After my fixes, I reran Lighthouse and our real user monitoring to ensure the performance gains showed up. They did. Always validate that your changes have the intended effect – occasionally you might “think” you removed 500KB but due to some quirk it didn’t actually happen. Trust but verify.
Key Takeaways
- Separate client and server code: Avoid modules that import heavy server-side libraries and are also imported in client-side code. Splitting such modules can drastically reduce bundle size by keeping server code out of the client bundle.
- Use analysis tools: Incorporate bundle analyzers and dependency visualization tools into your workflow. They can pinpoint exactly what’s bloating your app (e.g. a giant third-party library or an accidental import).
- Tree shaking has limits: Write your code in a tree-shake-friendly way. That means using ES modules, avoiding cross-environment entanglement, and marking pure exports or side-effect-free modules appropriately. Don’t rely on the bundler to magically know your intent.
- Lazy-load when appropriate: If certain code isn’t needed immediately, use Next.js dynamic imports or React lazy/Suspense to load it later. Your initial load will thank you.
- Regularly audit your bundles: Over time, new dependencies or changes might creep in and increase your bundle size. Make performance auditing a routine part of development, not just a one-time thing.
With these practices, you can keep your Next.js application lean and fast, giving users a better experience. In my case, solving the tangled imports issue was a game-changer for performance. Now the initial load is quick, and I can confidently say my app isn’t shipping JavaScript that it doesn’t need. 🎉