All articles
Web DevelopmentAugust 9, 2026 7 min read

The Client Bundle Creep: How Third-Party SDKs Quietly Doubled Our JS Payload

A war story about how six innocent-looking SDKs turned a lean Next.js App Router build into a 480 KB client bundle, and the audit playbook we now run before every launch.

We shipped a marketing site refactor last quarter that was supposed to be faster than the version it replaced. It wasn't. LCP was fine, but INP on mid-range Android had regressed by roughly a third, and the client JS payload had grown from around 240 KB to 480 KB gzipped. Nothing in the PR history looked expensive. The culprit was six third-party SDKs, each one added over four months by different squads, each one "just a few kilobytes."

This is the audit we now run before any Next.js App Router project goes to production, and the patterns that stopped the creep from coming back.

Why bundle creep is a Next.js problem specifically

The App Router made most of our own code cheaper. Server Components don't ship to the client. Route-level code splitting is essentially free. That's exactly why third-party SDKs stand out: when your own code is lean, a single "use client" component that pulls in an analytics vendor's full ESM entry point becomes the largest thing in the graph.

There's also a subtler issue. A lot of vendor SDKs are written assuming a classic SPA, where you pay for them once at boot. In an App Router app, if you import that SDK inside a component used on multiple layouts, it can end up in more than one chunk, or worse, in the root layout's client bundle where every route pays for it.

The outcome is predictable: TTI and INP degrade on the routes that matter (landing pages, pricing, checkout), while the routes you actually tested (dashboard, admin) look fine because users there are already engaged.

The audit: measure before you argue

Before blaming anyone, get real numbers. We use three tools in sequence, and none of them are exotic.

1. @next/bundle-analyzer for the shape

This is table stakes but worth configuring correctly. The default output lumps a lot together; you want per-route client chunks.

// next.config.ts
import type { NextConfig } from 'next';
import withBundleAnalyzer from '@next/bundle-analyzer';

const analyzer = withBundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
  openAnalyzer: false,
});

const config: NextConfig = {
  experimental: {
    optimizePackageImports: [
      'lodash-es',
      'date-fns',
      '@radix-ui/react-icons',
    ],
  },
};

export default analyzer(config);

Run ANALYZE=true next build and look specifically at client.html. Ignore the server bundle — it doesn't ship to browsers. What you're hunting for: any single module over 30 KB gzipped, and anything appearing in the shared chunk that shouldn't be global.

2. Chrome DevTools coverage on a real route

Bundle analyzer shows what's built. Coverage shows what actually executes. Load the route, click around for ten seconds, then check the Coverage tab. If a 60 KB SDK is 4% used, you have a lazy-loading problem, not a size problem.

3. Web Vitals from real users, not lab data

Lighthouse lies about INP because it doesn't simulate real interaction patterns. Ship a lightweight RUM script (or use the one built into Vercel Analytics, Sentry, or whatever you already pay for) and segment by route and device class. In our experience the gap between lab INP and p75 field INP on mid-range Android is often 3–4x.

The four SDK patterns that cause the most damage

After enough audits, the same shapes keep showing up.

Pattern 1: The barrel import that defeats tree shaking

// Bad: pulls the whole SDK graph
import { track } from 'some-analytics-sdk';

// Better: subpath import if the vendor supports it
import { track } from 'some-analytics-sdk/tracking';

Many vendors ship an index.js that re-exports everything, and their sideEffects: false flag is either missing or lying. You can confirm by importing a single function and checking whether unrelated modules still appear in the analyzer output. If they do, either use a subpath, file an issue, or wrap the SDK yourself.

Pattern 2: SDKs initialised in a root layout Client Component

The classic sin. Someone adds a <Providers> client component to app/layout.tsx that initialises analytics, session replay, feature flags, and a chat widget. Every single route now pays for all of it before hydration completes.

The fix is to split providers by concern and load the non-critical ones after interaction or after requestIdleCallback.

// app/providers/analytics-provider.tsx
'use client';
import { useEffect } from 'react';

export function AnalyticsProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    const idle =
      'requestIdleCallback' in window
        ? window.requestIdleCallback
        : (cb: IdleRequestCallback) => setTimeout(cb, 200);

    idle(() => {
      import('./analytics-client').then(({ init }) => init());
    });
  }, []);

  return <>{children}</>;
}

The SDK itself lives behind a dynamic import, so it never enters the initial chunk. You still get the events, just not at the cost of TTFI.

Pattern 3: Widgets that could be iframes

Chat widgets, video players, embedded forms — most of these vendors ship a script that could be an iframe. If a vendor offers both an SDK and an embeddable iframe endpoint, the iframe almost always wins on main-thread cost. It runs in its own process, and your INP no longer depends on their JavaScript quality.

We replaced a 90 KB chat SDK with a lazy-mounted iframe on our own marketing site. INP on the pricing page dropped by roughly half.

Pattern 4: Duplicated dependencies

Two SDKs both bundle their own copy of zod, superstruct, or a date library. pnpm why <pkg> will tell you. Sometimes you can hoist with resolutions / overrides; sometimes the SDKs pin incompatible majors and you're stuck. In that case, the honest answer is to pick one vendor and drop the other.

Server Components as a bundle governance tool

One underused trick: if an SDK has a Node-friendly API, use it from a Server Component or Server Action and don't ship the client version at all.

A good example is feature flags. Many vendors offer both a client SDK (for real-time updates) and a server SDK (for evaluation at render time). If you don't need real-time updates on a marketing page, evaluate flags on the server:

// app/(marketing)/pricing/page.tsx
import { getFlag } from '@/lib/flags-server';

export default async function PricingPage() {
  const showNewTier = await getFlag('pricing.new-tier');

  return (
    <PricingTable variant={showNewTier ? 'v2' : 'v1'} />
  );
}

Zero client JS from the flags vendor. The variant is baked into the HTML. You lose live updates, but on a page that revalidates every few minutes anyway, no one notices.

The same logic applies to CMS SDKs, translation SDKs, and anything that only needs to run at request time.

Making the audit stick: budgets in CI

A one-off audit fixes the number for a week. What keeps it fixed is a bundle budget that fails the build.

// scripts/check-bundle.ts
import { readFileSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
import { globSync } from 'glob';

const LIMITS = {
  'app/layout': 90_000, // gzipped bytes
  'app/(marketing)': 140_000,
  'app/(app)': 220_000,
};

const chunks = globSync('.next/static/chunks/**/*.js');
// ...group by route segment, sum gzipped sizes, compare to LIMITS
// exit 1 if any bucket is over

The exact implementation varies by project — some teams use size-limit, others parse the Next.js build manifest directly. The point is that the number becomes visible in every PR. Once a reviewer sees "+42 KB to marketing bundle" in the CI comment, the conversation about whether to add another SDK becomes much shorter.

What to say to product when they ask for another SDK

This is the part engineers usually lose. Product wants attribution, session replay, A/B testing, a chat widget, a survey tool, and a support popover. Each request is reasonable in isolation.

The framing that works: every SDK has a p75 mobile INP cost, and INP is a ranking signal and a conversion signal. You can quantify roughly what a 100 KB increase does to your funnel using your own analytics. Then it stops being "engineering says no" and becomes "this SDK costs us an estimated X% of pricing-page conversions — is the feature worth it?"

Most of the time the answer is still yes. But sometimes it's "can we get the same signal from server-side events?" and that's the conversation you actually wanted.

Where we'd start

If you inherit a Next.js app that feels slow and you don't know why, do this in order: run @next/bundle-analyzer and screenshot the client output; run Coverage on your three highest-traffic routes; list every third-party SDK and mark which ones could be server-side, iframed, or deferred. You'll usually find 100–200 KB to remove in an afternoon, and a repeatable budget to prevent it from coming back. If you'd like a second pair of eyes, our web development team does these audits regularly, and the patterns above are what we bring to them.

#Next.js#Performance#React#Web Development

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project