← All writing
Craft · · 10 min

Nothing was loading

On the difference between the user moving the page and something arriving on screen, and the API that answers the second one.

JavaScript Performance

You could open a product, hit the back button, and land on a category page with nothing on it.

Not an error and not a spinner. The header, the filters, and then a page and a half of nothing.

Everything was there. Every card was in the DOM with its image loaded and its price correct, sitting at opacity: 0, waiting for a class that nobody was going to add.

The ticket said the products were loading slowly on the way back. So we spent most of a day in the API and the cache, which is where you go when something is loading slowly.

Nothing was loading.

Waiting for a scroll that already happened

The grid has a reveal on it. Cards fade and rise a few pixels as they come into view, staggered down the page. The designer drew it, the client liked it, I built it and enjoyed building it, and we’ll get to that.

The class that does the fading gets added by this, which I’ve written in some form on nearly every project I’ve worked on:

reveal.js
// what was already there
window.addEventListener('scroll', () => {
  items.forEach(el => {
    const box = el.getBoundingClientRect();
    if (box.top < window.innerHeight) el.classList.add('is-visible');
  });
});
JavaScript

Everybody has written that. Throttle it, batch the reads, put the whole thing in a requestAnimationFrame, and you get the respectable version of it, which is what I had.

The back button restores your scroll position. history.scrollRestoration is the browser doing you a favor, and the restore happens while the page is still coming up. I never proved the ordering to my own satisfaction, but from a few console.logs I suspect the restore was landing before my script attached its listener, so the one scroll event that would have run the handler fired into an empty room.

After that, nothing. You’re already where you wanted to be, so you don’t scroll, and a scroll handler on a page nobody is scrolling never runs again.

A person seeing an empty-looking page scrolls it, at which point the whole grid fades in behind them and the bug reads as slow loading. It also doesn’t happen in every browser, so half the people who went looking for it came back saying the page was fine.

I’d patched this twice already

The fix is two lines. Attach the listener, then call the handler once by hand, so the page gets an evaluation at load that doesn’t depend on anybody moving.

It worked, and it was the third patch of that exact shape on that one function. There was one for resize from the previous fall, because the columns reflow at a breakpoint and the boxes move and nothing scrolled. There was one from when the filters went in, because the grid re-renders with new cards and nothing scrolled. Now one for the restore.

Each of them was small, each one was correct, and each one was the same sentence with a different noun in it: something arrived on screen and nobody scrolled.

Which is the point where I noticed the question was wrong. A scroll event tells you the user moved the page. I wanted to know whether a thing is on screen. Those two agree most of the time, which is exactly why the code mostly works, and every bug I’d patched was living in the space where they come apart.

That space is bigger than it sounds. A font swaps and everything below it reflows. Images arrive and push the page around, which is an argument of its own. A filter re-renders a grid. An accordion opens. The window resizes, somebody follows an anchor link, the browser restores a scroll position. Not one of those is a scroll and every one of them changes what’s on screen.

There’s a second problem with the handler, which I’d known about for years and which had never once made me delete anything. getBoundingClientRect() hands back where the element is right now, so the browser has to bring layout up to date before it can answer. Read a position, write a class, read the next position, and you’ve made it redo layout for every card on every frame. That’s layout thrashing. It’s why these handlers don’t get gently worse as the list grows, they fall over.

I knew all that. I’d throttled the handler, and on my laptop the profile looked survivable. On a throttled mid-range phone it was not survivable. I still hadn’t touched it, because scrolling that feels slightly sticky is something you can look at and decide to live with. An empty page is not.

What the browser already knows

The browser is doing this math anyway. It knows where every box is and when that changes, because it can’t paint otherwise. IntersectionObserver is the part where you get to ask.

You describe what you’re interested in once, and it calls you when the answer changes. Off the main thread, batched, no forced layout, and it costs about nothing while nothing is crossing.

reveal.js
const io = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;

    entry.target.classList.add('is-visible');
    // one-shot, so stop watching it
    io.unobserve(entry.target);
  }
}, { rootMargin: '0px 0px -10% 0px', threshold: 0 });

document.querySelectorAll('.reveal').forEach(el => io.observe(el));
JavaScript

Same effect as the scroll handler, no scroll handler.

The reason it fixed the back button isn’t that it’s faster. It’s that “is this on screen” is the actual question. So it gets answered when you arrive, whether you got there by scrolling, resizing, filtering, or coming back from a product page. IE11 has none of it and needs a polyfill, and whether IE11 is still on the support matrix is a conversation and not a technical question.

The sentinel trick

Half of what I use it for isn’t watching a real element at all. You put an empty div where a question lives and observe that instead.

An empty element after the last product, and when it comes into view, fetch the next page. That’s load-more in a few lines. (Whether you should build infinite scroll at all is a separate argument and the answer is usually no, especially on anything with a footer in it!)

A 1px element at the very top of the page, and when it leaves, the header is stuck, so add the class. People answer that question with a scroll listener and a magic number that stops being true the moment somebody changes the height of the hero. This version has no number in it.

Impression tracking is the same mechanism pointed at a different question. Did this block genuinely appear on screen, and for how long, which is a useful way to find out whether the thing everyone argues about in meetings is ever actually seen. It’s also exactly how ad viewability measurement works, so it’s worth being clear with yourself about which of the two you’re building.

Lazy loading used to be the headline use and mostly isn’t now, because there’s an attribute for it that does the common case natively, everywhere except Safari. Which is a small illustration of how this goes. A general-purpose primitive turns up, everybody builds the same handful of things with it, and then the platform absorbs the most common one.

rootMargin is the interesting one

It’s the parameter that does the real work and the one everybody skips past.

It grows or shrinks the box that counts as “in view,” in margin syntax. Positive values fire early, before the element is really there, which is what you want for prefetching. Negative values fire late, when the thing is actually on screen instead of one pixel into it.

rootmargin.js
// fire 200px early, for prefetching
{ rootMargin: '200px' }

// only the middle 20% of the screen counts, which is the scrollspy answer
{ rootMargin: '-40% 0px -40% 0px' }
JavaScript

That second one is the whole answer to a table of contents that highlights the section you’re in. Shrink the root to a band across the middle of the screen, and “the current section” is just whatever is intersecting the band. All the miserable math about which heading is nearest the top goes away.

Units are pixels or percentages. Not em, not rem, which I’ve gotten wrong twice and which fails quietly instead of throwing.

Obligatory gotchas

The callback fires as soon as you observe something, once per element, with the current state. If you’ve written it assuming it only runs on a change, page load is a surprise, and if half your elements are already on screen it’s several surprises at once.

Unobserve anything one-shot. If a reveal only needs to happen once then stop watching after it fires, or you’re keeping a live observation on every card on the page for the life of the tab. Cheap, but not free.

Thresholds are crossings and not states. threshold: [0, 0.25, 0.5, 1] gets you a callback every time the ratio crosses one of those values, in both directions, which is what you want for a progress indicator and noise if all you wanted to know was whether the thing is there.

Intersecting is a geometry word

It isn’t a scroll API and it can’t be made into one. No position, no direction, no speed, and if you need those you still need a scroll listener, though you can usually get direction by comparing an entry’s rectangle to the last one you saw.

And intersecting is a fact about geometry, not about eyeballs. An element can be intersecting the viewport while it sits behind a modal, under a cookie banner, at opacity: 0, or beneath a visibility: hidden on some ancestor it’s never heard of. Given the page I opened this post with, that’s a distinction I’d already met from the other side. There’s a second version of the API that tries to answer whether a human could actually see the thing, built for the ad fraud problem. It’s in one browser and I’ve never used it.

The animation I complained about in 2018

The reveal is the least defensible use on that whole list and it’s the one I was there for.

I wrote in 2018 about a parallax hero I was proud of that gave a coworker a headache for an afternoon, and then I spent a week in 2021 making a grid of cards slide up the page more efficiently. Check prefers-reduced-motion before you attach a single observer, fade instead of travel, and keep the travel small enough that it reads as the page settling instead of the page moving. That gets you most of the way to it being fine, and it doesn’t undo the fact that I built it.

The other thing I took out of that week is smaller and I’ve thought about it more since. The cards were hidden in CSS and revealed by JavaScript, so the empty page I got off the back button was the page every visitor without working JavaScript had been getting since the day I built the thing. The hidden state comes from a class the script puts on <html> on its way in now, so no script means no class, no hiding, and the grid is just a grid. Nobody had ever reported that version of it, because the people it happens to have no way of knowing there was supposed to be a grid there at all. I’d never once thought about them until the back button handed me the same screenshot.

Read similar posts
9 min

Two bars in the parking garage

A page on two bars doesn't fail, it just sits there, and that turned out to be the only state where the thing I spent an evening building does anybody any good.

5 min

jQuery for one selector

Most of what jQuery was invented to fix has been fixed in the browsers, and a lot of us are still loading the whole library to avoid typing a slightly longer method name.