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 regularimport. 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 typeis 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:
- Does this genuinely need to run on every single route? Most things that get added to
_appfor 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. - 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.
- 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