Prefetch on touch in an Astro site
Moving between pages on this site felt slow on my phone. Taps felt slow too. The site already scores 100 on PageSpeed Insights, so I first checked whether Astro was the problem.
What I checked
- The viewport tag is
width=device-width, initial-scale=1, so phones don’t add the old 300ms tap delay. - Google Analytics waits for the first interaction, so no script blocks a tap.
- Vercel serves every page from its cache, and pages are 45 to 92 KB.
- No
unloadhandlers, so Back comes from the browser’s page cache.
Astro was fine. Each page is plain HTML. The slow feeling came from three things:
- Nothing loads before the tap. Every tap starts a fresh request, and you wait the full round trip.
- No press state. My hover styles only run on devices with a mouse, so on a phone a tap shows nothing until the next page draws.
- The old page goes blank before the new one paints.
Astro’s prefetch
Astro has prefetch built in, so I turned it on first:
prefetch: { prefetchAll: true, defaultStrategy: 'hover' },
Then I read its source in node_modules/astro/dist/prefetch/index.js. The hover strategy listens for mouseenter and focusin. A phone has no hover, so it only fires as the tap turns into a click, too late to help. The tap strategy listens for touchstart and mousedown, but on desktop that throws away the hover head start.
It also cost me on first load. The prefetch code ships as three small script files on every page. In Lighthouse on mobile, the home page took 1.4 to 1.6s to show its largest content, up from 1.2s.
A small inline script
So I removed the config and wrote my own. It starts loading a page on touch, or after the pointer rests on a link for 80ms:
(function () {
var c = navigator.connection;
if (c && (c.saveData || /2g/.test(c.effectiveType))) return;
var done = {}, timer;
function go(a) {
var u = new URL(a.href, location.href);
u.hash = '';
if (u.origin !== location.origin || u.pathname === location.pathname || done[u.href]) return;
done[u.href] = 1;
var l = document.createElement('link');
l.rel = 'prefetch';
l.href = u.href;
document.head.appendChild(l);
}
function at(e) { return e.target.closest && e.target.closest('a[href]:not([data-no-prefetch])'); }
addEventListener('touchstart', function (e) { var a = at(e); if (a) go(a); }, { passive: true });
['mouseover', 'focusin'].forEach(function (n) {
addEventListener(n, function (e) {
var a = at(e);
clearTimeout(timer);
if (a) timer = setTimeout(function () { go(a); }, 80);
}, { passive: true });
});
})();
- It skips Data Saver and 2G connections, like Astro does.
- Only links to this site are loaded. Email, GitHub and download links are left alone.
<link rel="prefetch">puts the page in the browser’s cache, so the click finds it already there.- The 80ms wait means a pointer passing over a link doesn’t load it.
- Any link can opt out with
data-no-prefetch.
Astro doesn’t minify is:inline scripts, so every comment inside one ships to every visitor. The live version is compacted onto a few lines, and the comment explaining it sits above it as an Astro comment, which is stripped at build.
Press state and cross-fade
Both are CSS:
@media (hover: none) {
html { -webkit-tap-highlight-color: transparent; }
a:active, button:active, summary:active, select:active { opacity: 0.6; }
}
@media (prefers-reduced-motion: no-preference) {
@view-transition { navigation: auto; }
}
- On touch screens, links and buttons dim while pressed. This replaces the browser’s grey tap box.
@view-transitioncross-fades between pages instead of a blank flash. It works in Chrome, Edge and Safari 18.2 and later. Other browsers just navigate.
What Lighthouse said
I ran Lighthouse on mobile three times per page, before and after:
- Home: still 100 in all four scores. Largest content went from 1.20s to 1.35s.
- /posts/: still 100. Largest content stayed between 1.05 and 1.20s.
The home page is slower in the test, and that part didn’t work out the way I wanted. I took the changes out one at a time. The cross-fade made no difference, and the CSS alone mostly stayed at 1.20s. The script adds about 400 bytes to the compressed page. My best guess is that this pushes the home page over a size step in Lighthouse’s simulated network, which then counts one more round trip. On the live site, PageSpeed Insights still shows 100 in all four scores.
Taps now get an answer right away, and the next page starts loading as soon as a finger touches the link. Before this, I made sure a tap that leaves the page still gets counted: Google Analytics events when GA loads late.