You've built a sticky header. It works. You ship it.
Then someone opens your site on a slow connection, scrolls down, and the whole page jolts — content jumps up by a few pixels right as the header sticks. It looks broken. And the worst part? You can't reproduce it locally, because on your machine the fonts are already cached.
I hit this exact bug in a navigation plugin I maintain. Here's what's actually happening, and the fix that finally killed it.
The setup
A sticky header that's position: fixed is pulled out of the document flow. To stop the content below from jumping up the moment the header detaches, you insert a placeholder — a spacer — with the same height as the header:
var header = document.querySelector('.site-header');
var spacer = document.querySelector('.header-spacer');
spacer.style.height = header.offsetHeight + 'px';
Measure the header once, set the spacer, done. On every demo this works perfectly.
Why it breaks in the real world
The trap is when you measure.
If your header uses a custom font (Google Fonts, a self-hosted webfont, anything loaded asynchronously), the browser renders the page first with a fallback system font, then swaps in the real font once it downloads. This is the font-display: swap behavior — great for performance, brutal for layout measurements.
So the sequence on a cold load is:
- Page renders with Arial (fallback). Your header is, say, 64px tall.
- Your JS runs, measures 64px, sets the spacer to 64px.
- The webfont finishes downloading. Your header re-renders in Poppins, which has a slightly taller line-height. Now the header is 68px tall.
- The spacer is still 64px. There's now a 4px mismatch — and the moment the header goes sticky, the content jumps to close that gap.
On your dev machine steps 3 happens instantly (font is cached), so you never see it. On a real visitor's first load, the font arrives hundreds of milliseconds later. That's the jump.
The fix
The browser gives you a promise that resolves when all fonts have finished loading: document.fonts.ready. Re-measure when it fires.
function recalcSpacer() {
spacer.style.height = header.offsetHeight + 'px';
}
// Measure now (fallback font)...
recalcSpacer();
// ...and again once the real fonts are in.
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(recalcSpacer);
}
// Fonts aside, the header also changes height on resize.
window.addEventListener('resize', recalcSpacer);
That's the core of it. But there was one more wrinkle.
The wrinkle: measuring an element that's already fixed
If the header is already sticky when the font swaps (visitor scrolled fast), offsetHeight can return the wrong value, because a fixed element's box doesn't always reflect what the spacer needs. The fix is to measure it in its natural state: briefly remove the fixed class, read the height, put it back — all synchronously, so the browser never paints the intermediate state and the user sees nothing.
function recalcSpacerForce() {
var wasFixed = header.classList.contains('is-sticky');
if (wasFixed) header.classList.remove('is-sticky');
spacer.style.height = header.offsetHeight + 'px';
if (wasFixed) header.classList.add('is-sticky');
}
document.fonts.ready.then(recalcSpacerForce);
Because the class is removed and re-added within the same synchronous block — no await, no setTimeout — the layout change never reaches the screen. The browser batches it. You get the correct measurement with zero visible flicker.
The takeaway
Any layout measurement you take before fonts load is provisional. If you're setting heights, offsets, or scroll thresholds based on text-containing elements, hook into document.fonts.ready and measure again. It's one line, and it removes a class of "it works on my machine" bugs that are almost impossible to catch in dev.
I shipped this in a free WordPress menu plugin (Giuliomax Menu Builder) where the sticky header is user-configurable with any Google Font — which is exactly the scenario that surfaces this bug at scale. If you want to see it in a real codebase: https://github.com/Giulio001/menux-free-version
Has this one bitten you too? Curious how others handle layout measurements around async fonts.
wordpress: https://wordpress.org/plugins/giuliomax-menu-builder/
Top comments (0)