Back to Blog
engineering

How we cut public-page transfer 57% by deleting our bundle config

September 2, 2026
How we cut public-page transfer 57% by deleting our bundle config

How we cut public-page transfer 57% by deleting our bundle config

Every one of Linkgo's 1,078 public tool pages was shipping a charting library and a drag-and-drop library that only the admin dashboard uses. They had been going out with every page for months, and the code responsible for it was a block in next.config.js labeled "bundle optimization."

The problem

Linkgo is a catalog, so almost all of its traffic lands on public routes: the category indexes and /tools/[slug] detail pages. Those pages are text, a screenshot, some pricing metadata, and an FAQ. There is no chart on them and nothing to drag.

The numbers said otherwise. The shared chunk — the JavaScript every route loads before it loads anything of its own — was 663 kB. A single tool detail page came to 678 kB of first-load JS. Averaged across all routes it was 664 kB, which is a suspicious number on its own: if per-route splitting is working, routes should differ from each other. Ours barely did.

The thing that made this worth chasing rather than filing was egress. We had been working through a list of infrastructure cost reductions, and network transfer was the line item that refused to move.

Why it happened

The config looked like this:

webpack: (config, { dev, isServer }) => {
  if (!dev && !isServer) {
    config.optimization.splitChunks = {
      chunks: 'all',
      cacheGroups: {
        default: false,
        vendors: false,
        vendor: { name: 'vendors', chunks: 'all', test: /node_modules/ },
        common: { name: 'commons', minChunks: 2, priority: -10, reuseExistingChunk: true },
      },
    }
  }
  return config
},

Two lines do the damage. default: false and vendors: false disable webpack's built-in cache groups — which, in a Next.js app, are the mechanism the framework configures to split code per route. With those off, the replacement vendor group matches everything under node_modules and emits it as one chunk named vendors. One chunk, every route.

So the shape was: turn off the framework's route-aware splitting, then hand-roll a rule with no route awareness at all. Once all third-party code lives in a single chunk, importing recharts anywhere in the app means recharts is on the wire everywhere in the app. The admin dashboard imports it. The public pages paid for it.

There was a second, quieter cost. chunks: 'all' at the top level overrides Next's protection that keeps the polyfills and main entries out of splitting. We didn't know that was happening until it stopped happening.

The config was not written maliciously or even carelessly. It's close to a snippet that circulated widely in the webpack 4 / Next 9 era, when the framework's own defaults were thinner and a manual vendor chunk really did help. It stopped being true, and nothing in the build told us. The bundle got bigger and the file that caused it was named for making bundles smaller, so every time someone scanned the config for problems, that block read as part of the solution.

What we tried first

The instinct was to add another rule: keep the custom splitChunks and carve recharts out into its own async cache group, or make the admin imports dynamic so they'd code-split away.

We didn't ship that. It treats the framework default as the thing to work around. Every rule stacked on a config that had already disabled the right behavior is another rule to maintain, with the original misconfiguration still underneath it for the next person to build on. The correct diff for a config that turns off a good default is not a patch. It's a deletion.

The fix

We removed the webpack key entirely and let Next 15 do its own chunking. What replaced it is a comment explaining why the block is gone, so the next person to open the file finds an answer instead of an absence:

// Bundle chunking — no custom splitChunks here. Do not add it back.
//
// This block previously set `default: false` / `vendors: false`, which turns
// off Next's per-route splitting and packs all of node_modules into a single
// `vendors` chunk loaded on every route. Admin-only libraries were shipping
// to all 1,078 public tool pages as a result.

Before trusting the comparison we confirmed the baseline build produced chunk hashes identical to production, so we were measuring against the real thing. Then: build exit 0 with an error list byte-identical to the previous one; all 310 manifest assets served 200 through the standalone server, which is the actual production entry path; zero new browser console errors; no server secrets in any client chunk.

Before and after

Measured on Next 15.5.19:

Before After
Shared chunk 663 kB 102 kB −85%
/tools/[slug] first load 678 kB 240 kB −65%
All-route average 664 kB 122 kB
Admin routes, average 667 kB 171 kB
Real transfer, home (HTML + assets, gzip) 738 KB 319 KB −57%

The last row is the one that matters. First-load JS is a build-time accounting number; transfer is what leaves the server compressed and arrives at a device. We report both because the ratio between them is itself informative — a 85% drop in the shared chunk is a 57% drop on the wire.

Admin routes got faster too, which was not the goal but follows from the same cause: they had been loading the public catalog's dependencies for exactly as long as the public pages had been loading theirs.

What we learned

  • A config that overrides a framework default is a claim that the default is wrong. That claim expires. Ours was probably true when it was written and silently false a few major versions later, and nothing re-evaluates it on your behalf.
  • Name things after what they do, not what you want them to do. "Bundle optimization" is why this survived every code review it passed through. A comment reading "disables Next's per-route splitting" would have been read very differently.
  • Deletion is a legitimate fix, and it should be defended in the diff. An empty space in a config file invites someone to fill it. The comment we left is longer than the code it replaced, on purpose.
  • Removing the config does not prevent the recurrence. recharts arrives through the module graph, not through chunk settings. If a public component imports recharts — or components/ui/chart.tsx, currently unused — it comes back to public routes regardless of how chunking is configured.

What's next

We set the verdict criteria before deploying, which matters here because this change's observation window overlaps another one (an image-optimization change in 0.2.146). Deciding after the fact whether a number "looks good" when two changes are in flight is how you learn nothing. The metric is monthly-normalized network transfer, judged today, 2026-09-02: at or below 22 GB/month is a success, at or above 36 GB/month is a failure.

The open item is the recurrence path. A chunk-size assertion in CI on the public route group would catch an admin-only dependency crossing back over — that's the guard we don't have yet, and the reason this post can't end with "fixed."


Try Linkgo

Linkgo is a curated directory of AI tools, agents, MCPs, and models — browse the catalog at linkgo.dev.

Website

Share

Frequently Asked Questions

Why was the public-page JavaScript bundle size so large before the fix?

The large bundle size was caused by a custom webpack splitChunks configuration that disabled Next.js's per-route code splitting. This forced all third-party libraries, including admin-only ones like recharts, into a single large 'vendors' chunk loaded on every public page.

What was the main change that reduced the public-page transfer size by 57%?

The main change was deleting the custom webpack splitChunks configuration entirely and letting Next.js handle chunking by default. This restored per-route splitting, preventing admin-only libraries from being included in every public page's bundle.

Why is deleting the custom bundle config better than patching it with additional rules?

Deleting the custom config removes the root cause—disabling Next.js's built-in splitting—rather than layering more complex rules on top. This reduces maintenance overhead and prevents future misconfigurations that could reintroduce the problem.

How did the team verify that removing the custom config did not break the build or app functionality?

They confirmed the baseline build produced chunk hashes identical to production, verified zero new browser console errors, ensured no server secrets leaked into client chunks, and tested all assets served correctly through the standalone server.

What measures are planned to prevent admin-only dependencies from reappearing in public bundles?

The team plans to implement a chunk-size assertion in continuous integration (CI) targeting public routes to detect if admin-only dependencies cross back into public bundles, serving as a guard against recurrence.

Continue reading

How we cut public-page transfer 57% by deleting our bundle config | Eodin