Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 6 min read

The heaviest thing on our report page is a 469KB chunk that arrives 30 seconds late

CogniPrep generates four different PDF reports: one per practice session, a master analysis across every session, one per assessment centre exercise, and one per mock interview. They are all built with @react-pdf/rendere

CogniPrep generates four different PDF reports: one per practice session, a master analysis across every session, one per assessment centre exercise, and one per mock interview. They are all built with @react-pdf/renderer, which is a genuinely pleasant library. You write a document as React components and it hands you a PDF.

It is also by far the heaviest thing in the client bundle, and it started life as a top level import in a 'use client' page. Which means every visitor who opened a report on screen downloaded and parsed a PDF engine they were statistically unlikely to use.

The measurement, taken live a few minutes ago

Open a session report in a fresh tab and ask the browser what it fetched:

performance
  .getEntriesByType('resource')
  .filter((r) => r.name.includes('/_next/static/chunks/'))

On the live site that is 34 chunks, 817KB compressed, largest single chunk 187KB.

Then press Download PDF and run the same snippet again:

36 chunks, 1288KB. Two new chunks, one of them 469KB compressed on its own, with a startTime thirty seconds into the session because that is when I clicked. It is two and a half times the size of the next largest chunk in the app, and it grew the page's entire JavaScript payload by 57%.

That is the whole argument for the lazy import in one comparison, and you can run it yourself in about forty seconds.

The fix is four lines and one honest comment

async function handleDownload() {
  if (!report) return;
  setDownloading(true);
  try {
    // Loaded on click, not at import.
    //
    // lib/reports/session-pdf statically imports @react-pdf/renderer, which
    // builds to a ~1.4MB client chunk - the largest in the app by a factor of
    // two, and about a quarter of all client JS. As a top-level import in this
    // 'use client' page, every visitor downloaded and parsed it just to render
    // the report, even though most never press Download. The `downloading`
    // state already covers the button while this resolves, so the wait is
    // visible.
    const { generateSessionPDF } = await import('@/lib/reports/session-pdf');
    await generateSessionPDF({ /* ... */ });

The detail that made this free to ship is the last sentence of that comment. A downloading boolean already existed, because generating a PDF from a big document is not instant even when the code is warm. So the extra latency of fetching the chunk lands inside a spinner the user was already going to see. No new UI state, no layout shift, no "why did my button do nothing for 400ms".

All four call sites do the same thing, and three of them just point at the first one rather than repeating the rationale. A comment that says "see the sibling page for why" is better than four copies of an explanation that will only be updated in one of them.

Lazy loading only works if the module graph agrees

Splitting a chunk out is easy to undo by accident. Two habits keep it split:

Shared styles are shared, single use styles are local. There is one pdf-styles.ts holding the style objects that two or more documents use, and each document keeps its own StyleSheet.create for the bits only it renders. If every style lived in the shared module, lazily loading the interview report would drag in the style objects for the session, master and exercise reports as well. Small in bytes, but it is the same mistake as a barrel file: one import pulls the whole neighbourhood.

Nothing else may import the generators. The four modules are only ever reached through await import() inside a click handler. A single innocuous top level import { PDFReportData } from '@/lib/reports/session-pdf' for the sake of a type would pull the renderer straight back into the parent chunk. Type-only imports are erased, but import type is what guarantees that, and a plain import of a type is not the same thing.

Rendering on the client is an architecture choice, not laziness

The obvious alternative is a server route: POST the report, render it with headless Chromium or a PDF library, stream it back. That trade is worth naming explicitly, because the bundle cost above is the only real argument against client rendering, and it is the cheapest of the costs involved.

Rendering in the browser means:

  • there is no PDF endpoint, so there is no authorization decision about who may render whose report, and no route to rate limit
  • the data is already in the page. The report the user is looking at is the report that gets rendered, so the two cannot disagree, and nothing round trips to be formatted
  • no server side font loading, no headless browser in the deploy, no queue, no temporary storage, no signed URL with an expiry that someone has to pick
  • it scales with users' own CPUs, which is the only compute budget in the system that grows for free

The cost is 469KB, paid by the fraction of users who press the button, plus the inability to email someone a PDF they did not ask for. For a report that only ever exists because a signed in person clicked Download, that is the right side of the trade.

The bug we wrote down instead of fixing

All four generators end up in the same six line helper:

export async function downloadPdf(element: PdfDocumentElement, filename: string): Promise<void> {
  const blob = await pdf(element).toBlob();
  const url = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = url;
  link.download = filename;
  link.click();
  URL.revokeObjectURL(url);
}

revokeObjectURL runs synchronously on the line after click(), which in theory can race the browser starting the download. It has never been observed to fail, and it is the behaviour that existed before these four modules were split out of one big utility file.

It got a comment saying exactly that, rather than a fix, because the change that created this file was a refactor. A refactor that also quietly alters behaviour is two changes wearing one commit message, and when something breaks a week later nobody can tell which half did it. Write the suspicion down, keep the diff mechanical, fix it on purpose afterwards.

The type on that signature is worth a glance too:

type PdfDocumentElement = NonNullable<Parameters<typeof pdf>[0]>;

@react-pdf/renderer exports its document props through a declare namespace, so the element type is not importable by name. Deriving it from the function's own parameter keeps it correct across library upgrades without naming anything the library does not export.

See it

The numbers above were taken on the live site, and you can reproduce them on a report of your own. Reports are part of the paid Scores and Reports upgrade, so the full walkthrough needs an account with that unlocked. What the pricing page is free to tell you is which parts of the reporting are gated, and its second card is explicit about it.

With a report open:

  1. Run the performance.getEntriesByType('resource') snippet above in the console. Note the chunk count and total.
  2. Press Download PDF.
  3. Run it again. Two new chunks appear, one of roughly 469KB, with a startTime matching the moment you clicked, and it is the largest chunk the page has loaded.

The technique transfers to any app: pick your heaviest dependency, find the interaction that actually needs it, and check whether the bytes arrive before or after the click. The snippet is three lines and it tells you the truth about your own bundle without a build step or an analyzer.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.