Engineering at Car & Classic · 2/4

4,000 photos that load only when you want them: a virtual carousel in Vue

How Car & Classic listing cards show 100+ photos each, Airbnb-style, without janking the homepage: intent-based loading, paged photos, a virtual track.

On this page

The listing card is the face of Car & Classic: a car’s photos, its name, and the details that make someone click. The homepage shows about 40 of them, and a listing can have more than 100 high-resolution photos, all swipeable right in the card, like on Airbnb.

Do that the obvious way and the homepage asks for thousands of large images and renders thousands of slides. The page gets heavy, scrolling janks, and phones suffer most. This is how I built the carousel so it doesn’t.

The idea: intent decides what loads

Most photos in most cards are never seen. So the carousel treats every photo as “not needed yet” until the visitor shows intent, and each signal unlocks a little more:

SignalWhat loads
Page loadone photo per card in the HTML; only cards above the fold download it, at high priority
Card near the viewportthe carousel takes over from the server-rendered photo
Hover (desktop)the next couple of photos, so the first swipe is instant
First swipe or clicka few photos ready on each side of the current one
Near the last loaded photothe next page of ten photo URLs

On phones there’s no hover, so the first swipe is the signal, and the carousel keeps a slightly wider window around the current photo once someone starts swiping.

Server-rendered, but not downloaded

Every card’s first photo is in the server-rendered HTML, so the layout is right from the first paint and nothing shifts. But only the cards above the fold, the ones that decide how fast the page looks, get a real image URL, fetched eagerly at high priority. That’s what the Largest Contentful Paint measures.

Every other card’s first photo has its URL parked in a data attribute. The browser sees an image element with the right size and nothing to download. When the card comes near the screen, an intersection observer (with a margin ahead of the viewport) hands over to the carousel, which loads the real photo.

Photos arrive in pages

A listing can have over a hundred photos, and the card doesn’t need to know them all up front. It starts with a handful of URLs from the page data. When the current photo gets close to the last loaded one, the carousel fetches the next ten from a paginated endpoint, and on hover it already asks for the next page, so the first swipes never wait on the network.

Each photo comes in small, medium and large variants from the image service.

Why it has to be virtual

Lazy loading solves the network: photos nobody looks at are never downloaded. It doesn’t solve the DOM. If every card rendered every slide, the homepage would hold around 4,000 slides: forty cards with a hundred photos each, and each slide is an image plus the elements around it. The browser pays for every one of those nodes, whether or not the photo has loaded:

  • Style and layout get slower. Every time something changes (a card is hovered, a slide moves, the page resizes) the browser recalculates styles and layout, and the cost grows with the number of elements it has to walk.
  • Memory grows. Each node costs memory, and every photo that does load is kept decoded as a full bitmap, far bigger than the file that was downloaded. Phones run out first.
  • Frames get dropped. To look smooth at 60 frames per second, the browser has about 16 ms per frame for everything: scripts, styles, layout, painting. With thousands of slides in the page, a swipe or a scroll regularly blows that budget, and the carousel stutters and skips frames instead of following your finger.

So the rule is the same as for the network: only pay for what’s on screen.

Only the slides around you exist

The track is virtualised horizontally. The carousel knows how many photos a listing has and how wide one slide is, but it only renders a small window of slides: the current one and a few neighbours on each side. Even with fifty photos loaded, that window is all the DOM ever holds.

Each slide in the window is placed at its real position, its index times the slide width, with a translate3d, so the track behaves as if every photo were there. When you swipe, the window moves with you: the slide that falls out of range is removed, the one coming into range is created, and the ones that stay keep their elements, because each slide is keyed by its photo. The number of nodes stays the same whether the listing has five photos or a hundred and fifty.

The translate3d also puts each slide on its own GPU layer, so moving it doesn’t repaint anything else, and contain: content tells the browser that nothing inside a slide can change the layout outside it, so swapping one doesn’t reflow the rest of the page.

The virtual carousel's window around the current photoComponents: photo 1–3, photo 4, photo 5, photos 6–7, photos 8–10, photos 11–20 (queue). Connections: photo 4 ↔ photo 5; photo 5 ↔ photos 6–7; photos 8–10 → photos 11–20 (fetch when close).fetch when closephoto 1–3not in the DOMphoto 4loadedphoto 5current · high priorityphotos 6–7loaded · low priorityphotos 8–10URL onlyphotos 11–20next page
Only the slides inside the window exist in the DOM. Photos outside it are URLs in memory, or not fetched yet.

Swiping uses native CSS scroll snapping, so it feels like the platform on every device. A virtualised track makes snapping tricky, because the browser can only snap to elements that exist, so the carousel keeps a small window of invisible snap targets around the current photo: one behind, a few ahead. You can swipe fast in either direction and always land on a photo.

Why not a library

At Car & Classic we build this kind of thing in-house rather than pulling in packages, so the question was never which carousel library to install. And we had a reason beyond policy: we’d used Embla elsewhere, and across browsers it janked, because of the CSS it applies internally, which we couldn’t tune for our cards. Owning the carousel meant owning every line of CSS it ships.

Native lazy loading on its own (loading="lazy") wasn’t enough either. It solves the downloads, but, as above, not the thousands of slides in the DOM.

The hard part: CSS or JavaScript

The hardest part wasn’t any single feature. It was deciding, for each behaviour, whether CSS or JavaScript should own it.

Everything CSS can do natively, it does more reliably than JavaScript: it runs off the main thread where it can, it doesn’t wait for a script to load or a frame to be scheduled, and it keeps working when the page is busy. So the rule was CSS for motion and layout, JavaScript only for decisions. Scroll snapping, the swipe itself, the fades and the containment are CSS. JavaScript decides which slides exist, which photos to load and when, and nothing else. Every behaviour moved from JavaScript to CSS was one less thing that could stutter while the page was busy hydrating forty cards.

But CSS isn’t free either, and some of it had to be tuned. The clearest case was high refresh rate screens. On a 120 Hz monitor the browser has about 8 ms per frame instead of 16, and the first version of the slide movement flickered there: it relied on properties the browser works out on the CPU, repainting on every frame. Moving the slides with transforms, so each one sits on its own layer and the GPU just moves it, made the flicker go away. The same thinking applied the other way: promoting too many elements to their own layers costs GPU memory, so only the slides in the window get one.

The small things

  • Only the current photo is urgent. It loads eagerly at high priority; the neighbours load at low priority and decode asynchronously, so they never compete with it.
  • No pop-in. Each photo fades in once it has actually loaded, so a swipe never shows a half-drawn image.
  • A failed page of photos doesn’t break the card. The carousel keeps what it has and stays usable.
  • Built from composables. Viewport detection, progressive loading, virtual scrolling and the carousel mechanics are separate composables, each testable on its own, and the carousel component just orchestrates them.

The result

Loading everythingVirtual carousel
Photos requested on first loadevery photo of every cardonly the cards above the fold
Slides in the DOMall of thema few per card
More photosalready downloaded, mostly never seenon hover, swipe and scroll, ten at a time

It feels like every photo is already there, because the next one always is.

The trade-offs

  • What it protected: performance and smoothness, above all on phones: fewer downloads, fewer nodes, and motion that holds up on a 120 Hz screen.
  • What it cost: our own carousel to maintain. Virtualisation and scroll snapping have browser quirks, and those are now ours to handle. That’s why the logic is split into composables that can each be tested on their own.
  • What it risked: the first photo. Anything that loads late can make the page jump or the main image appear late, so the first photo of every card stays in the server-rendered HTML with its size set.

Spot it in your own app

  • Run document.querySelectorAll('*').length in the console on your heaviest page. Tens of thousands of nodes on one page is a smell.
  • Record a swipe or a scroll in the Performance panel. Long tasks and red frames while it moves mean the page is doing too much work per frame.
  • Turn on Paint flashing and Layer borders in the Rendering panel. If a whole card repaints every time a slide moves, the movement isn’t on the GPU.

What I’d take from it

Most performance work on the front end is deciding when, not how. Loading a photo is easy; knowing which of four thousand photos someone is about to look at is the actual problem. Treat every signal the user gives you, near the screen, hover, first swipe, as permission to load a little more, and nothing else.