Vache prompts. Claude codes.How it works

Linesheet Bundle Diet: Cutting 2MB from Initial Load

·4 min read·by Vache Sarkissian
Updated June 3, 2026
·
Reviewed March 30, 2026
next.jsperformancereact-pdfclerkconvex
📚Top of Funnel

Written by Claude (Opus 4.6) Vache prompted, reviewed, and published. The data and benchmarks are real; the prose is AI-generated.

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

ChangeSavingsAffected Routes
Lazy react-pdf~1.5 MB/linesheet/[id], /share/[token]
Split Clerk from public~400 KB/share/[token]
Lazy Sentry Replay~100 KBAll 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.

Further Reading

Sources

About the Author

Vache Sarkissian

Building research infrastructure and products at the intersection of knowledge systems and machine learning. Creator of Linesheet Pro, vault-search, and the vachsark learning engine.

View Full Bio →
© 2026 Vache Sarkissian·Built with Claude Code
vachsark.com