Prefetching the Next Page

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

I have a crazy idea. What if, when the user hovers over a link, we prefetch that page via Ajax and saved it to the snapshot cache? Then, assuming the user does click that link, Turbo would show the page instantly via its preview system.

Is that possible? Well, not officially. But thanks to some clever people on the Internet, it is! Let's learn two different ways that can we can make the performance of our site even faster... and the caveats that go with both - neither is perfect out-of-the box. But both are super interesting.

Prefetching on Hover

If you downloaded the course code, you should have a tutorial/ directory with a prefetch.js file inside. Copy that and paste it into assets/turbo/.

// https://gist.github.com/vitobotta/8ac3c6f65633b5edb2949aeff0dec69b
// This code is to be used with https://turbo.hotwire.dev. By default Turbo keeps visited pages in its cache
// so that when you visit one of those pages again, Turbo will fetch the copy from cache first and present that to the user, then
// it will fetch the updated page from the server and replace the preview. This makes for a much more responsive navigation
// between pages. We can improve this further with the code in this file. It enables automatic prefetching of a page when you
// hover with the mouse on a link or touch it on a mobile device. There is a delay between the mouseover event and the click
// event, so with this trick the page is already being fetched before the click happens, speeding up also the first
// view of a page not yet in cache. When the page has been prefetched it is then added to Turbo's cache so it's available for
// the next visit during the same session. Turbo's default behavior plus this trick make for much more responsive UIs (non SPA).
import * as Turbo from '@hotwired/turbo';
let lastTouchTimestamp
let delayOnHover = 65
let mouseoverTimer
const pendingPrefetches = new Set()
const eventListenersOptions = {
capture: true,
passive: true,
}
class Snapshot extends Turbo.navigator.view.snapshot.constructor {
}
... lines 28 - 153

Ok: this is not my script: it comes from a gist that I attributed on top. This script automatically makes an Ajax call whenever a user hovers over an anchor tag and saves the response as a Turbo snapshot. Then, if the user does click that link, the page will be displayed instantly thanks to the preview. To avoid totally spamming the server with requests, this code waits for the user to hover for 65 milliseconds before sending the Ajax request. The idea is to take advantage of the brief pause between when a user starts to hover over a link and when they actually click that link. This approach does have some downsides, but let's see it in action before we chat about them.

Open up app.js and import this: import './turbo/prefetch'. That's enough to activate the new behavior.

16 lines assets/app.js
... lines 1 - 12
import './turbo/turbo-helper';
import './turbo/prefetch';

Also open up styles/app.css and comment-out the opacity transition that we added before. The pages are going to be so fast that this won't be needed.

... lines 1 - 8
/*
body.turbo-loading {
opacity: .8;
}
*/
/*
[data-turbo-preview] body {
opacity: .2;
}
*/
... lines 19 - 175

Moment of truth. At your browser, refresh. I'm going "casually" click on the Furniture category. Woh - that was fast! All these pages are now loading as if we've already visited them... because... we actually have! The perceived performance of our site just took another huge step forward.

The Downsides of the Hover Prefetch

But that was too easy! So what are the downsides? There are a few. The first is that your site is going to get hit by a lot more requests. If you hover over a link but never click it, that's an extra, unnecessary request! But worse, even if you do click the link, two requests are made! Watch, I'll refresh, then clear my network tools. Hover over "Office Supplies", then click. Check it out: two requests were made for the same page! The prefetch script made the first request to store the page as a snapshot for the preview. But then, like normal preview functionality, after showing the preview, Turbo made a second request to load a "fresh" version of the page. That's a bummer.

Another downside is that, if your page doesn't load fast enough, this won't make any difference! For example, let me clear the network tools again. I'm going to hover and then click "Breakroom" really fast. Watch: that time, the page did not load instantly because the first prefetch request had not finished by the time I clicked.

In fact, when you look at the second request that Turbo made, it "stalled": the second request waited for the first. To be fully honest, I'm not actually sure why my browser waits like this... but it means that if the user clicks before the prefetch request finishes, it may actually be slowing down the experience.

The last problem is that the prefetch script will also try to prefetch links that we don't want it to - like a "log out" link. Yup, right now, if we hovered briefly over a log out link, that... would log us out.

In the script, search for dataset. You can add a data-prefetch="false" attribute to any link to disable the behavior for that link. Or, by customizing this line a little, you could disable the prefetch behavior by default and only enable it if the link has data-prefetch=true. That would be a safe way to enable this only on links that make sense to you.

... lines 1 - 91
function isPreloadable(linkElement) {
if (!linkElement || !linkElement.getAttribute("href") || linkElement.dataset.turbo == "false" || linkElement.dataset.prefetch == "false") {
return
}
... lines 98 - 153

There's also another way to use this script, which you can see at the bottom. If you add a data-prefetch-with-link="true" attribute, instead of making an Ajax call, it will add a <link rel="prefetch"> element to your head tag.

... lines 1 - 133
function preload(link) {
const url = link.getAttribute("href")
const loc = new URL(url, location.protocol + "//" + location.host)
const absoluteUrl = loc.toString()
if (link.dataset.prefetchWithLink == "true") {
const prefetcher = document.createElement('link')
prefetcher.rel = 'prefetch'
prefetcher.href = url
document.head.appendChild(prefetcher)
pendingPrefetches.delete(absoluteUrl)
} else if (!Turbo.navigator.view.snapshotCache.has(loc)) {
fetchPage(url, responseText => {
const snapshot = Snapshot.fromHTMLString(responseText)
Turbo.navigator.view.snapshotCache.put(loc, snapshot)
pendingPrefetches.delete(absoluteUrl)
})
}
}

What does that do? It enables a really neat feature that's native to your browser. Let's learn about it next.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=7.4.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "1.11.99.1", // 1.11.99.1
        "doctrine/annotations": "^1.0", // 1.13.1
        "doctrine/doctrine-bundle": "^2.2", // 2.3.2
        "doctrine/orm": "^2.8", // 2.9.1
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^6.1", // v6.1.4
        "symfony/asset": "5.3.*", // v5.3.0-RC1
        "symfony/console": "5.3.*", // v5.3.0-RC1
        "symfony/dotenv": "5.3.*", // v5.3.0-RC1
        "symfony/flex": "^1.3.1", // v1.13.3
        "symfony/form": "5.3.*", // v5.3.0-RC1
        "symfony/framework-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/property-access": "5.3.*", // v5.3.0-RC1
        "symfony/property-info": "5.3.*", // v5.3.0-RC1
        "symfony/proxy-manager-bridge": "5.3.*", // v5.3.0-RC1
        "symfony/runtime": "5.3.*", // v5.3.0-RC1
        "symfony/security-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/serializer": "5.3.*", // v5.3.0-RC1
        "symfony/twig-bundle": "5.3.*", // v5.3.0-RC1
        "symfony/ux-chartjs": "^1.1", // v1.3.0
        "symfony/ux-turbo": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.0-RC1
        "symfony/webpack-encore-bundle": "^1.9", // v1.11.2
        "symfony/yaml": "5.3.*", // v5.3.0-RC1
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.1
        "twig/intl-extra": "^3.2", // v3.3.0
        "twig/string-extra": "^3.3", // v3.3.1
        "twig/twig": "^2.12|^3.0" // v3.3.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
        "symfony/debug-bundle": "^5.2", // v5.3.0-RC1
        "symfony/maker-bundle": "^1.27", // v1.31.1
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/stopwatch": "^5.2", // v5.3.0-RC1
        "symfony/var-dumper": "^5.2", // v5.3.0-RC1
        "symfony/web-profiler-bundle": "^5.2", // v5.3.0-RC1
        "zenstruck/foundry": "^1.10" // v1.10.0
    }
}