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
| Section | What it was doing | Proposal |
|---|---|---|
| Carousels (auctions, cars and bikes, performance cars) | Sending every card, though about 5–7 show on load | Server-render the visible cards, fetch the rest after load |
| Today’s Picks | 20 cards at the very bottom of the page, fully server-rendered | Load the whole section asynchronously |
| Popular makes | 94 makes, all rendered | Cap at 50 |
| UK regions | 119 regions, all rendered | Cap at 50 |
| Sidebar links | Dynamic counts nobody displayed | Static links |
| Quick filters | Server-rendered, not needed for search | Fetch asynchronously |
| SEO sections | Carrying far more data than they displayed | Send 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”.