What We Fixed
Linesheet's Next.js production bundle included 2MB of unused JavaScript on the initial page load. Three dependencies were loaded eagerly on every page despite being needed only on specific user actions.
How to Cut 2MB from Your Bundle
The fix uses dynamic imports to load heavy libraries only when users need them. Instead of bundling react-pdf, Clerk, and Sentry Replay on every page, we moved them to lazy-load patterns that defer the download until the specific feature is used.
Impact: Initial page load bundle reduced 2MB. Time-to-interactive improved by ~1.2s on 4G. No feature changes.
The Three Offenders
1. react-pdf: 1.5MB on Pages That Don't Generate PDFs
react-pdf bundles yoga-layout (WASM), pdf-lib, and fontkit. It was imported at the top of usePdfDownload.ts and EmailLinesheetModal.tsx — meaning every visit to a linesheet page or share link loaded 1.5MB of PDF infrastructure, even if the user never clicked "Download" or "Send Email."
The fix: move to dynamic import() inside the functions that actually need it.
async function generatePdf(items, style) {
const { pdf } = await import("@react-pdf/renderer");
const { LinesheetDocument } = await import("./LinesheetDocument");
// Now we have react-pdf — only when the user clicks "Download"
const blob = await pdf(
<LinesheetDocument items={items} style={style} />
).toBlob();
return blob;
}The chunk still exists — it's just fetched on demand instead of on page load.
2. Clerk: 400KB on Public Share Pages
Clerk's authentication JS (~400KB) was loading on every route because ClerkProvider wrapped the root layout. But share pages (/share/[token]) are public — they don't need auth.
The fix: move ClerkProvider into (protected) and (auth) route group layouts, and create a ConvexPublicProvider for unauthenticated pages.
app/
layout.tsx ← No ClerkProvider here anymore
(protected)/layout.tsx ← ClerkProvider + ConvexProviderWithClerk
(auth)/layout.tsx ← ClerkProvider (for login/signup)
share/layout.tsx ← ConvexPublicProvider (no Clerk)
Share links now load without any auth overhead. The ConvexPublicProvider connects to Convex directly without Clerk token injection — just a few lines of code.
3. Sentry Session Replay: ~100KB Loaded Eagerly
Sentry's Session Replay integration was statically imported in sentry.client.config.ts. It's useful for debugging production issues, but it doesn't need to block initial render.
Sentry.init({
integrations: [
Sentry.replayIntegration({
maskAllText: true,
blockAllMedia: true,
}),
],
});Changed to:
Sentry.init({
integrations: [
Sentry.lazyLoadIntegration("replayIntegration", {
maskAllText: true,
blockAllMedia: true,
}),
],
});One function call swap. Sentry loads the replay chunk asynchronously after the page is interactive.
The Bonus Bug: Query Purity
While auditing the codebase, I found a Convex query handler calling Date.now():
// Before — breaks reactive caching
export const getInventoryAlerts = query({
handler: async (ctx) => {
const now = Date.now();
// ...filter items expiring before `now`
},
});Convex query handlers must be pure — no Date.now(), no Math.random(). These break Convex's reactive caching because the function produces different results on re-evaluation even when the underlying data hasn't changed. The fix is to pass the timestamp from the client:
export const getInventoryAlerts = query({
args: { todayDate: v.number() },
handler: async (ctx, { todayDate }) => {
// ...filter items expiring before `todayDate`
},
});This is a rule I've been burned by before — it's in my CLAUDE.md checklist. The heartbeat system's rule-effectiveness task flagged it as a recurring pattern, which is what prompted me to look for it here.
Results
| Change | Savings | Affected Routes |
|---|---|---|
| Lazy react-pdf | ~1.5 MB | /linesheet/[id], /share/[token] |
| Split Clerk from public | ~400 KB | /share/[token] |
| Lazy Sentry Replay | ~100 KB | All routes |
| Total | ~2 MB | — |
13 files changed, 175 insertions, 80 deletions. Two commits. No user-facing behavior changes — the app does exactly what it did before, just faster on first load.
The pattern here isn't complicated: if a dependency is only needed in response to a user action, don't load it on page render. import() is the escape hatch. Next.js route groups are the escape hatch for provider-level splitting. Both are zero-config in a modern stack — you just have to remember to use them.