DEV Community

Daniel Pertu
Daniel Pertu

Posted on

More JavaScript arrives after our landing page loads than before it

Open cogniprep.app with an empty cache and a Network panel filtered to JS, then sort by the time each request started. Here is what I measured on the deployment that is live as I write this:

requests transferred
before loadEventEnd (1053 ms) 19 295 KB
after loadEventEnd 8 325 KB

More than half the JavaScript the page ever downloads is not requested until the page has finished loading. That is not an accident and it is not lazy route loading. It is two SDKs that used to sit in the initial bundle and now do not.

If you want the same numbers yourself, paste this in the console:

const js = performance.getEntriesByType('resource').filter(r => /\.js(\?|$)/.test(r.name));
const loadAt = performance.timing.loadEventEnd - performance.timing.navigationStart;
const kb = a => Math.round(a.reduce((s, r) => s + (r.transferSize || 0), 0) / 1024);
console.table([
  { when: 'before load', n: js.filter(r => r.startTime <= loadAt).length, kb: kb(js.filter(r => r.startTime <= loadAt)) },
  { when: 'after load',  n: js.filter(r => r.startTime >  loadAt).length, kb: kb(js.filter(r => r.startTime >  loadAt)) },
]);
Enter fullscreen mode Exit fullscreen mode

The two largest late arrivals, 187 KB and 90 KB, both start at about 1074 ms, twenty milliseconds after the load event. They are the Sentry browser SDK and posthog-js. Neither of them draws a pixel or answers a click.

Why they were a problem in the first place

The error reporter is the single largest library in our shared client bundle at roughly 300 KB uncompressed. A default Next.js Sentry setup puts it at the top level of instrumentation-client.ts, which means every visitor on every route downloads and executes it before the page can settle. Analytics has exactly the same shape.

Both are tools for us. Neither is a feature the visitor came for. Paying for them in Largest Contentful Paint is the wrong trade, and on a landing page it is the worst possible route to pay it on.

The naive fix loses errors

Moving the SDK behind a dynamic import() scheduled at idle is three lines. The problem is the window you open: anything thrown between the first tick and the moment the SDK finishes loading is thrown into a void. Hydration errors live in exactly that window, and they are the errors you least want to miss.

So the module attaches two cheap listeners from the very first tick, before anything is imported:

const MAX_BUFFERED = 20;
const bufferedErrors: unknown[] = [];

function bufferError(error: unknown): void {
  if (bufferedErrors.length < MAX_BUFFERED) bufferedErrors.push(error);
  void ensureSentryLoaded();
}

if (typeof window !== 'undefined') {
  window.addEventListener('error', (e) => bufferError(e.error ?? e.message));
  window.addEventListener('unhandledrejection', (e) => bufferError(e.reason));
}
Enter fullscreen mode Exit fullscreen mode

Two details in there matter more than the idea.

The cap. A tight error loop can throw thousands of times in a second, and an uncapped array in the middle of that is a memory leak that only manifests on the pages that are already broken.

The call to ensureSentryLoaded() inside the buffer. An error is itself a reason to stop waiting for idle. If something throws at 200 ms, the SDK load is triggered at 200 ms rather than whenever the browser next has a quiet moment.

When the SDK does arrive, it takes over and drains the queue:

window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleRejection);
for (const error of bufferedErrors.splice(0)) {
  Sentry.captureException(error);
}
Enter fullscreen mode Exit fullscreen mode

Scheduling is requestIdleCallback with a timeout, and a setTimeout fallback because Safari still does not implement it:

if (typeof window.requestIdleCallback === 'function') {
  window.requestIdleCallback(schedule, { timeout: 5000 });
} else {
  setTimeout(schedule, 2000);
}
Enter fullscreen mode Exit fullscreen mode

Session Replay is a second layer of the same decision

Listing replayIntegration in the integrations array of Sentry.init means every visitor downloads the recorder, whatever your sampling rate says. Our configuration is replaysSessionSampleRate: 0 and replaysOnErrorSampleRate: 1.0, so a statically listed recorder would be shipped to everyone in order to be used by almost nobody.

It is attached after init instead, through the SDK's own loader, and its failure path is empty on purpose:

Sentry.lazyLoadIntegration('replayIntegration')
  .then((replayIntegration) => {
    Sentry.getClient()?.addIntegration(replayIntegration({ maskAllText: true, blockAllMedia: true }));
  })
  .catch(() => {
    // Replay unavailable this session. Error and tracing reporting continue.
  });
Enter fullscreen mode Exit fullscreen mode

Losing replay for one session is a small cost. Letting a failed CDN fetch reject into an unhandled rejection, on a page whose job is to report errors, is a funny kind of outage.

The boundary that must not import the reporter

There is one more place where a static import undoes all of this. app/global-error.tsx is the last-resort boundary in the Next.js App Router, and the obvious thing to write in it is import * as Sentry from '@sentry/nextjs'. That single line pulls the SDK back into the initial client bundle for every route, because the boundary is part of the root layout's graph.

useEffect(() => {
  void import('@sentry/nextjs')
    .then((Sentry) => Sentry.captureException(error))
    .catch(() => {
      // Reporting is best effort. Never let it block the fallback UI.
    });
}, [error]);
Enter fullscreen mode Exit fullscreen mode

This component renders when the app has already failed. Downloading 300 KB at that moment is completely acceptable. Downloading it on the 99.99% of page views where the component never renders is not.

What this does not fix

Deferred bytes are still bytes. The visitor on a slow connection still downloads them, just after the content rather than instead of it. Two SDKs off the critical path is a real improvement to LCP and does nothing at all for total transfer.

The thing worth copying is not the idle callback. It is asking, of every library in your initial bundle, whether the visitor is waiting for it or you are. Error reporting, product analytics, session replay and web vitals are all in the second group, and all four of ours now arrive after the page is usable. You can watch them land in the waterfall on cogniprep.app: the interesting rows are the ones that begin after the blue load line.

Top comments (0)