Engineering at Car & Classic · 3/4

What the homepage renders first: 125 KB to 43 KB

A section-by-section audit of the Car & Classic homepage decided what to server-render, what to load later and what to cap, cutting page data by two thirds.

On this page

With Inertia, a Laravel page sends its data to Vue as props, embedded in the HTML. It’s convenient, and it’s also easy to send far more than the page renders, because nothing on screen tells you the rest is there.

When the new UK homepage was being built, its page data had grown to 125 KB gzipped, before a single image. Before changing anything, I wrote up where it all came from, section by section, and took it to the SEO team and product with a yes-or-no question for each one.

Why it’s worth a meeting

Speed on the homepage isn’t only about feel: Google uses page experience in its rankings, so for a marketplace it’s an SEO question too. Each part of the proposal targeted one measure:

  • LCP (Largest Contentful Paint): server-render only what’s visible, so the browser spends its effort on the hero, not on images below the fold.
  • Interactivity: big arrays of data, like every card of every carousel or a list of 119 regions, have to be parsed and hydrated before the page responds. Less data up front, a page that’s usable sooner.
  • DOM size: hundreds of off-screen nodes cost memory and slow every update, especially on phones.
  • TTFB (Time to First Byte): a smaller page is also less for the server to build.

Where the weight was

SectionWhat it was doingProposal
Carousels (auctions, cars and bikes, performance cars)Sending every card, though about 5–7 show on loadServer-render the visible cards, fetch the rest after load
Today’s Picks20 cards at the very bottom of the page, fully server-renderedLoad the whole section asynchronously
Popular makes94 makes, all renderedCap at 50
UK regions119 regions, all renderedCap at 50
Sidebar linksDynamic counts nobody displayedStatic links
Quick filtersServer-rendered, not needed for searchFetch asynchronously
SEO sectionsCarrying far more data than they displayedSend only what’s rendered

Some sections were a clear yes. For others the answer was a middle ground: the carousels, for example, stay server-rendered with enough listings for search engines to see real content, while keeping the first paint quick.

The part you don’t see in the payload: hydration

Bytes weren’t the only cost. Today’s Picks alone was 20 cards, each using five composables: about a hundred composable instances starting on hydration, twenty intersection observers, and a WebSocket subscription for every auction card in view. Loading that section after the page is up takes all of it off the critical path, instead of making the first interaction wait for it.

Images: the next problem

The same audit looked at images, as a finding rather than a fix. Scrolling the whole homepage without touching anything meant around 40 cards of photos at roughly 120 KB each, plus logos: about 5.7 MB of images. For comparison, Airbnb serves its card photos at roughly 15 KB each in AVIF, and loads only about twenty on first paint. The virtual carousel already stops most of those photos from loading until someone wants them; the image format and size are the next step.

The result

The page data went from 125 KB to 43 KB gzipped, about two thirds less, and the heaviest section left the hydration path entirely. Every visitor downloads, parses and hydrates that much less on the page most of them see first.

The trade-offs

This one is the clearest example of two quality attributes pulling against each other. Performance wanted less server-rendered content; SEO wanted real content in the HTML for search engines to read. Neither side was simply right, which is why every section got its own yes, no or middle ground, agreed with the people who own each concern.

It also has a cost of its own: anything loaded after the page is up needs its space reserved, or the page jumps when it arrives.

Spot it in your own app

If your app uses Inertia, paste this in the console on a page to see which props weigh the most:

const script = document.querySelector('script[data-page][type="application/json"]');
const page = JSON.parse(script ? script.textContent : document.getElementById('app').dataset.page);

Object.entries(page.props)
  .map(([key, value]) => [key, JSON.stringify(value).length])
  .sort((a, b) => b[1] - a[1])
  .slice(0, 10);

Newer versions of Inertia put the page data in a <script type="application/json"> tag; older ones keep it in the data-page attribute of the app element. The snippet reads whichever is there.

Anything large that the first screen doesn’t render is a candidate to load later, cap, or trim.

The lesson

It’s a rule I came back to later with the filter modal: shape the data around what the screen needs. The difference is that page props never show up in the network tab as a separate request, so bloat hides in plain sight. Measure what your pages actually ship, and bring the numbers to the people who own the trade-offs: SEO and product said yes faster to a table of kilobytes than they would have to “it feels slow”.