Skip to main content

Improving Boot Performance

General changes that move Total Blocking Time (TBT) on a React/Next.js project, and why — independent of which component library, styling system, or state library the project happens to use. The goal is a tuned baseline: boot TBT below the tool's configured threshold (100ms by default), with no heavy library reachable before first paint. Apply these when standing up a new project, or to recover perf that regressed. Each section is change → why → verify; codeleap-perf analyze (see Getting Started) automates most of the static verification steps below into one command with a PASS/FAIL verdict.

The methodology: comment and measure

Every recommendation below was validated the same way: comment out (or revert) the one thing you're evaluating, rebuild, and measure the floor of ≥5 runs back-to-back on the same machine. Compare floors, not medians or averages — the floor strips transient machine contention. See Measuring Correctly for the full discipline.

# baseline — the candidate change commented out / reverted
bun run build && bun run start &
codeleap-perf measure 5 # note the TBT (and LCP) floor

# candidate — the one change applied, nothing else
bun run build && bun run start &
codeleap-perf measure 5 # note the TBT (and LCP) floor again

# only trust the delta if it's bigger than the spread you saw across
# the 5 runs within either single measurement

1. Static imports vs. dynamic imports for heavy dependencies

Change Any heavy third-party dependency — an error-monitoring SDK, an analytics SDK, a form validation library, an accessibility-primitives library — should be reached with a dynamic import() on every module reachable from boot, never a static import. Deferring the library's initialization call or first use is not enough: a static import * as X from '…' parses the entire module during boot regardless of when you actually call anything from it.

A code-split boundary that already exists is usually load-bearing. Don't "clean up" a dynamic import('./heavy-module') into a synchronous, statically-imported singleton just because it reads more simply — confirm with comment-and-measure first. A boundary that looks redundant is often hiding a large, transitively-reachable dependency graph that only stays off boot because of it.

// ❌ static import — parsed on boot even though init() only runs later
import * as Monitoring from 'monitoring-sdk'
export function initMonitoring() {
Monitoring.init({ dsn: '...' })
}

// ✅ dynamic import — the module isn't fetched or parsed until this runs
export async function initMonitoring() {
const Monitoring = await import('monitoring-sdk')
Monitoring.init({ dsn: '...' })
}

A one-shot init() call is the simple case. A service used from many call sites throughout the app's life needs the same dynamic boundary, but also needs to fetch and initialize the heavy module only once — not on every call site that needs it. A lazy singleton accessor gets both:

// getAuthService.ts — every caller awaits the same function; the heavy
// module is dynamically imported and initialized on the first call only
let authServicePromise: Promise<AuthService> | null = null

export function getAuthService(): Promise<AuthService> {
if (!authServicePromise) {
authServicePromise = import('./auth').then((m) => m.createAuthService())
}
return authServicePromise
}

// any component, any number of times — only the first call ever imports './auth'
const auth = await getAuthService()

This is exactly the boundary "cleaning it up into a singleton" would remove: replacing getAuthService() with a plain top-level import { authService } from './auth' makes ./auth statically reachable from wherever that import lives, folding its whole graph back onto boot — even though the call site using authService might not run until much later.

Why A static import is parsed by the bundler and (for many bundlers) executed during module evaluation regardless of when your code actually calls into it. Only a dynamic import() is a real code-split boundary that defers both the network fetch and the parse.

Verify Comment-and-measure the TBT floor with codeleap-perf measure before and after converting an import; codeleap-perf boot-graph scans your boot-reachable chunks for a symbol specific to the library's internals (not its package name — see the false-positive warning in Measuring Correctly) to confirm it's actually gone from boot, not just deferred in your source.


2. Defer non-critical initialization, not just the import

Change Being lazily imported isn't the same as being deferred until needed. A library whose setup isn't required for the very first paint — error-monitoring initialization, a background auth-state listener, an analytics boot call — can have its actual initialization deferred to requestIdleCallback (with a timeout fallback for browsers that don't schedule idle callbacks promptly) or to the user's first interaction with the page, on top of already being a dynamic import.

// defer init() to the first real interaction, with a timeout fallback so it
// still fires even on a page nobody touches
const events = ['pointerdown', 'keydown', 'scroll'] as const
let started = false
let fallback: ReturnType<typeof setTimeout>

function start() {
if (started) return
started = true
events.forEach((e) => window.removeEventListener(e, start))
clearTimeout(fallback)
import('monitoring-sdk').then((m) => m.init({ dsn: '...' }))
}

events.forEach((e) => window.addEventListener(e, start, { once: true, passive: true }))
fallback = setTimeout(start, 8000)

Why A dynamic import keeps the parse off the critical boot path, but if the resulting init() still runs synchronously as soon as the module resolves, it can still compete for main-thread time inside the same window Lighthouse measures as TBT. Pushing the call itself to idle time or first interaction moves that cost outside the measured window entirely.

Verify Comment-and-measure with codeleap-perf measure: compare the TBT floor with eager init() on mount versus deferred to idle/first-interaction. codeleap-perf cpu can confirm the deferred work now happens after the load event, not before it — cpu self-time is not the same signal as TBT, so use it to sanity check when the work runs, not to draw the perf conclusion itself.


3. No hook or module shared between the app root and a page without a boundary

Change If your app's root component (Next.js: _app.tsx) calls a hook or imports a module directly, and a page also imports that same module for its own UI, a bundler that doesn't dedupe identical modules across those two separately-loaded chunks can ship the whole thing twice. Wrap the app-root-level call in its own small component behind a client-only dynamic import instead, so it folds into whichever page's own chunk needs it rather than shipping a permanent, duplicated copy from the app root.

// ❌ _app.tsx calls the hook directly — its whole import graph now ships in
// the app-root chunk, on top of any page that already imports the same hook
import { useCurrentUser } from '@/hooks/useCurrentUser'

function MyApp({ Component, pageProps }) {
useCurrentUser()
return <Component {...pageProps} />
}
// ✅ fold the call into a client-only dynamic boundary instead
import dynamic from 'next/dynamic'

const UserSync = dynamic(() => import('@/components/UserSync'), { ssr: false })

function MyApp({ Component, pageProps }) {
return (
<>
<UserSync />
<Component {...pageProps} />
</>
)
}

// components/UserSync.tsx
import { useCurrentUser } from '@/hooks/useCurrentUser'
export function UserSync() {
useCurrentUser()
return null
}

Why This is a subtler case of the same static-vs-dynamic-import problem: even though neither individual import looks wrong on its own, the combination — the same graph reachable from two different boot-time entry points — can silently double a chunk's weight without either import itself doing anything unusual.

Verify Comment-and-measure with codeleap-perf measure: this is exactly the kind of gap a CPU profile alone won't surface (nothing about the code itself looks expensive), but a before/after floor comparison after removing the duplication will. codeleap-perf chunks confirms it structurally, independent of the timing measurement — add a marker for the shared symbol's name (see Reference) and check it appears in only one boot chunk, not two or more.


4. Tree-shake local/workspace packages properly

Change (Next.js) Enable the framework's package-import optimization for any local or workspace package whose default export shape is a large barrel (experimental.optimizePackageImports in Next.js). Do not add a bypass alias that resolves a package's imports to a different path — that kind of alias typically short-circuits the framework's tree-shaking optimization, putting the whole barrel's exports back on boot, including anything a given route never actually uses. If the package is a local/workspace dependency, also make sure the framework is configured to transpile it directly from source (Next.js: transpilePackages) rather than relying on a pre-built output.

For the tree-shaking to work at all, a local/workspace package's main entry point in its own manifest should resolve to real source (not a pre-bundled output) — a pre-bundled entry point re-introduces whatever that bundle includes, regardless of what the consuming app tree-shakes.

// next.config.js
module.exports = {
experimental: {
optimizePackageImports: ['@your-org/ui'],
},
transpilePackages: ['@your-org/ui'],
// ❌ do NOT add a resolveAlias / webpack alias for '@your-org/ui' here —
// it bypasses optimizePackageImports and puts the whole barrel back on boot
}
// packages/ui/package.json
{
"exports": { ".": "./src/index.ts" },
"main": "./dist/index.js"
}

Why Framework-level import optimization only tree-shakes when an import resolves to the package's actual source files, where the bundler can see individual export boundaries; resolving to a single pre-bundled file (via an alias, or because the package's manifest points there by default) hides those boundaries and the whole file loads together.

Verify codeleap-perf boot-graph and codeleap-perf chunks — comment-and-measure by temporarily reverting the config and confirming total boot JS size increases; separately, confirm a route that only imports a small piece of a local package doesn't pull in a large dependency that only a different piece of the same package needs.


5. Import discipline

Change

  • Deep-import a specific component or utility, never through a root barrel export — a barrel that re-exports an entire feature drags that feature's heaviest dependency onto every route that imports anything from it, even a single small piece.
  • Keep genuinely heavy dependency graphs (a validation library, an accessibility-primitives library, an image-cropping library) scoped to the routes that actually use them. A route that doesn't use any of that functionality — most commonly the homepage — should carry none of it.
  • When you only need a type from a package, import it with import type, not a regular import. A regular import that only happens to be used in type positions relies on your specific bundler being able to prove that across files and safely drop it — not every bundler does this reliably. import type is unconditionally erased at build time by any modern TypeScript-aware transpiler, with zero runtime cost, regardless of what the rest of your build setup can or can't prove.
// ❌ a regular import, even though only the type is ever used — whether this
// gets stripped depends on your bundler's ability to prove that safely
import { HeavyLibraryOptions } from 'heavy-library'

// ✅ import type is always erased, unconditionally, by any TS-aware transpiler
import type { HeavyLibraryOptions } from 'heavy-library'

// ❌ pulls in every component the feature exports, and each of their
// dependencies, even though this page only renders one of them
import { LoginForm } from '@/features/auth'

// ✅ only the one component (and its own dependencies) loads
import { LoginForm } from '@/features/auth/components/LoginForm'

Why A single barrel import can pull an entire feature's graph, and that graph's heaviest dependency, onto a route that only wanted one small, unrelated piece of it. A type-only import that isn't explicitly marked as such is a subtler version of the same risk — it can be safely stripped by some setups and not others, so it depends on how much you trust that inference.

Verify codeleap-perf imports inspects the third-party packages reachable from a given route's static boot import graph; run it per route — a heavy dependency should only appear on the routes that actually use the feature it belongs to. imports itself treats import type as erased when tracing the graph (it isn't counted), which doubles as a check on your bundler's own behavior — if a package still shows up despite every usage being type-only, either it isn't actually type-only somewhere, or something is pulling it in as a value import elsewhere.


6. Treat the app's entry points as the most sensitive files in the codebase

Change Hold anything added to the app's true entry points (Next.js pages router: _app.tsx and _document.tsx) to a higher bar than anywhere else. Every other file in the app — a page, a feature, a component — only ships to the routes that actually import it, so a heavy import there is naturally scoped away from routes that don't need it. _app/_document have no such scoping: whatever they import, directly or transitively, ships on every single route, with no per-route code-splitting boundary to fall back on. A one-line addition here can quietly become the single most expensive import in the entire app.

The fix is not to relocate the same static import to a different file — that only moves where the dependency is declared, not whether it still ships on every route. Before adding anything to either file, ask, in order:

  1. Does this genuinely need to run on every single route? Most things that get added to _app for convenience don't — they only matter for one route, or one feature, or one user state. If so, it belongs on that page/feature instead, not in the entry point at all.
  2. If it truly is global, does it need to run eagerly, on every load? If not, apply the dynamic-import and defer-to-idle/interaction techniques from sections 1–3 — genuinely global and genuinely needed-on-every-route code is rare enough that most candidates fail this question, not the first one.
  3. Is there a lighter alternative that gets the same outcome? A smaller library, a native platform API, a subset/submodule of the same dependency — the cheapest fix is often not shipping the heavy thing at all, in any file.

Only once none of those apply should the thing actually run eagerly and globally — and even then, confirm the cost with comment-and-measure before merging it, not after a regression shows up.

Why A heavy import anywhere else in the app can be fixed by route-scoping it (section 5). An import in _app/_document has nowhere to be scoped to — it's already the boot graph itself, so the question isn't where to put the import, it's whether the import needs to exist there at all.

Verify codeleap-perf imports reads from bootEntry, which defaults to exactly these two files, and codeleap-perf boot-graph scans the build manifest's entry for the same two files — run both after any change to either file and treat a new or larger third-party dependency as a priority, since there's no route-scoping fallback for it. Comment-and-measure the specific addition (not just "the file changed this week") to attribute its real cost before deciding it's worth keeping as-is.


7. Rebuild clean before a build you intend to ship or measure

Change Clear the framework's build output directory before any build meant to represent production — don't rely on incremental/cached build output for a build you're about to deploy or measure. A stale build cache can retain artifacts from a previous build (an old manifest, old chunk names) alongside freshly generated ones, producing an inconsistent bundle that doesn't match either version cleanly.

{
"scripts": {
"build": "rm -rf .next && next build"
}
}

Why This is not a caching nicety — a mismatched combination of stale and fresh build artifacts can produce a real, reproducible performance regression that has nothing to do with your actual code changes, and it's easy to misattribute to whatever you changed most recently.

Verify If a TBT regression doesn't line up with any code change you can find via comment-and-measure, rebuild from a fully clean state and re-measure before concluding the regression is real.


Checklist

  • Every heavy third-party dependency is dynamically imported on every boot-reachable module
  • Non-critical SDK initialization is deferred to idle time or first interaction, not just lazily imported
  • No hook/module is reachable from both the app root and a page without a shared dynamic boundary
  • Local/workspace packages: framework import-optimization on, no bypass alias, transpiled from source, package manifests point at source
  • No root-barrel imports on a lightweight route; heavy dependency graphs are route-scoped
  • The build output directory is cleared before any build meant to represent production
  • Every import in _app/_document (and everything they transitively pull in) has been reviewed with extra scrutiny — there's no route to scope it away to
  • Every claim above was validated the same way: comment/revert, rebuild, measure the floor of five or more runs