Why We Switched CDNs: How Google's Core Web Vitals Led Us to Cloudflare Pages
Michael
As a quick refresher, the Core Web Vitals are a set of metrics developed by Google used to measure real-world user experience. They define an assessment where a website must score 'Good' in all three categories in the 75th percentile to pass. The data used in the assessment is collected from real world users. The metric thresholds are as follows:
'Good' Threshold | |
---|---|
Largest Contentful Paint | ≤2500ms |
First Input Delay | ≤100ms |
Cumulative Layout Shift | ≤0.1 |
You can measure your own websites Core Web Vitals using PageSpeed Insights, or if you're hosted on Cloudflare Pages, turn on Web Analytics and you'll get an excellent dashboard of this information.
Our website, at the time hosted on Netlify, recently had an influx of traffic from mobile users with sub-optimal network conditions. Where we used to pass Google's "Core Web Vitals Assessment" with great figures - this new user base resulted in our Time to First Byte figures exceeding 1.9s in the p75 interval. Pushing back the first response also pushes back the First and Largest Contentful Paints. Our Largest Contentful Paint figure became 3.7s in the p75 interval for this group of users.
When the network requires 1.9s to push the first byte to the user, it's very difficult to achieve the Largest Contentful Paint within the 2.5s benchmark required to pass the Core Web Vitals Assessment.
It's impossible for us to get HAR archives from these edge users, so we found we couldn't replicate our problems for Netlify. Trying another CDN to compare was our only option.
We have substantial in-house build infrastructure, which is much faster than any affordable cloud-based build solution. Gatsby is our static site generator, and we have custom plugins that handle long-lived build caches for expensive operations like video transcoding. As such we're only looking for web hosting, not an all-in-one build-and-host solution.
Considering Gatsby Cloud
When I originally contacted Gatsby Concierge, requesting some insight into our Core Web Vitals woes, they sent back:
Our Gatsby support organization is set up to support Gatsby Cloud. So we're not able to troubleshoot issues with Gatsby when you're running your site on Netlify. Without question, the best experience for your Gatsby site will be on Gatsby Cloud.
The marketing for this concierge service doesn't mention that anywhere. I wanted a paid support option for the OSS project I've worked with and contributed to for years. It's a bit of a shame they're using it primarily as a marketing funnel to Gatsby Cloud.
Gatsby Cloud forces you to build your site within their infrastructure, which for us would be slow and expensive. We would need to put in additional development effort to get our persistent caches to work on unfamiliar hardware outside of our network. There are many unknowns regarding if our FFMPEG plugin would even run in any reasonable time on their infrastructure.
Netlify, Vercel, and Cloudflare Pages all allow you to upload pre-built artifacts, which is our preferred way of dealing with a web host. Gatsby Cloud is also unique in artificially limiting you to 5000 pages on the $50 a month plan.
The final nail in the coffin was that they only have CDN points of presence in the US and the EU at any price point other than "contact us". Even their homepage seems to exclusively use points of presence in the US and the EU. I think there's a disconnect between their marketing and what they're offering to their average user. Looking through their showcased sites, the ones hosted on Gatsby Cloud only have points of presence in the EU and the US. Purchasing CDN services directly from Fastly (their upstream provider) would be the same price for our use case, with fewer limitations and more points of presence.
Measuring the TTFB stats for the Gatsby homepage (presumably hosted on Gatsby Cloud), while EU and US performance is comparable to Netlify and Cloudflare - Asia Pacific and Africa all had hot cache hits taking 400ms+. Even Sydney, Australia was doing round trips to Los Angeles for a TTFB time of 581ms. These numbers are much worse than all the other offerings. I don't know why a global CDN isn't standard, even if their builds only take place in 'preferred' data centers, especially since it's an explicitly stated feature of their hosting service.
Considering Vercel
Vercel looked very reasonable. Their Web Vitals Analytics only has a 7 day reporting window for the Pro plan which has a minimum cost of $20 per month. Their TTFB stats were very favorable.
TTFB Benchmark results
The methodology of this test is pretty crude, but it serves as an indicative result. Cloudflare and Netlify both served our production site on our production domain, whereas the Gatsby and Vercel figures are just of their respective homepages. Given this is a server TTFB test, the exact content served shouldn't matter as long as the caches are hot. The speedvitals TTFB test was run twice per CDN to warm up the cache, then the third result was used. Cache hits were validated by checking the x-cache
header.
Location | Cloudflare | Vercel | Netlify | Gatsby Cloud | Best |
---|---|---|---|---|---|
London, UK | 158 ms | 236 ms | 65 ms | 86 ms | Netlify by 21ms |
Paris, France | 81 ms | 218 ms | 291 ms | 47 ms | Gatsby Cloud by 34ms |
Sweden | 172 ms | 252 ms | 125 ms | 220 ms | Netlify by 47ms |
Finland | 176 ms | 251 ms | 151 ms | 49 ms | Gatsby Cloud by 102ms |
Belgium | 102 ms | 211 ms | 65 ms | 377 ms | Netlify by 37ms |
Madrid, Spain | 132 ms | 250 ms | 117 ms | 127 ms | Netlify by 10ms |
Milan, Italy | 130 ms | 48 ms | 484 ms | 87 ms | Vercel by 39ms |
Netherlands | 158 ms | 207 ms | 52 ms | 81 ms | Netlify by 29ms |
Warsaw, Poland | 77 ms | 357 ms | 95 ms | 165 ms | Cloudflare by 18ms |
Frankfurt, Germany | 97 ms | 235 ms | 35 ms | 42 ms | Netlify by 7ms |
Zurich, Switzerland | 205 ms | 178 ms | 379 ms | 92 ms | Gatsby Cloud by 86ms |
Las Vegas, US | 73 ms | 187 ms | 572 ms | 187 ms | Cloudflare by 114ms |
Los Angeles, US | 83 ms | 205 ms | 46 ms | 132 ms | Netlify by 37ms |
Iowa, US | 106 ms | 200 ms | 121 ms | 77 ms | Gatsby Cloud by 29ms |
South Carolina, US | 124 ms | 222 ms | 357 ms | 128 ms | Cloudflare by 4ms |
Northern Virginia, US | 81 ms | 310 ms | 34 ms | 155 ms | Netlify by 47ms |
Oregon, US | 292 ms | 55 ms | 213 ms | 156 ms | Vercel by 101ms |
Dallas, US | 81 ms | 95 ms | 123 ms | 158 ms | Cloudflare by 14ms |
Montreal, Canada | 94 ms | 255 ms | 69 ms | 151 ms | Netlify by 25ms |
Toronto, Canada | 101 ms | 78 ms | 83 ms | 94 ms | Vercel by 5ms |
São Paulo, Brazil | 111 ms | 152 ms | 31 ms | 697 ms | Netlify by 80ms |
Santiago, Chile | 85 ms | 755 ms | 224 ms | 478 ms | Cloudflare by 139ms |
Mumbai, India | 152 ms | 218 ms | 1200 ms | 953 ms | Cloudflare by 66ms |
Delhi, India | 192 ms | 95 ms | 262 ms | 1200 ms | Vercel by 97ms |
Taiwan | 117 ms | 63 ms | 188 ms | 543 ms | Vercel by 54ms |
Hong Kong | 70 ms | 100 ms | 159 ms | 746 ms | Cloudflare by 30ms |
Tokyo, Japan | 86 ms | 43 ms | 245 ms | 428 ms | Vercel by 43ms |
Osaka, Japan | 70 ms | 29 ms | 269 ms | 486 ms | Vercel by 41ms |
Seoul, South Korea | 443 ms | 27 ms | 619 ms | 618 ms | Vercel by 416ms |
Singapore | 489 ms | 173 ms | 27 ms | 686 ms | Netlify by 146ms |
Jakarta, Indonesia | 508 ms | 244 ms | 72 ms | 787 ms | Netlify by 172ms |
Sydney, Australia | 97 ms | 160 ms | 31 ms | 581 ms | Netlify by 66ms |
Melbourne, Australia | 342 ms | 197 ms | 79 ms | 688 ms | Netlify by 118ms |
Tel Aviv, Israel | 313 ms | 501 ms | 411 ms | 385 ms | Cloudflare by 72ms |
South Africa | 244 ms | 336 ms | 1200 ms | 1600 ms | Cloudflare by 92ms |
Average | 167 ms | 204 ms | 243 ms | 385 ms | Cloudflare by 37ms |
StdDev | 120 ms | 140 ms | 286 ms | 367 ms | Cloudflare by 20ms |
Overall Cloudflare was the winner with both a faster average TTFB and a tighter standard deviation.
If you're interested in the 'excel formula' to calculate the 'best' column, it is this monstrosity:
XLOOKUP(0,B2:E2,B$1:E$1,"None",1,1)&" by "& XLOOKUP(MIN(B2:E2)+0.5,B2:E2,B2:E2,0,1,1) − MIN(B2:E2)& "ms"
Cloudflare Pages
Cloudflare ran their own series of benchmarks comparing the CDN performance of Akamai, Cloudflare, Amazon CloudFront, Fastly, and Google. They came out on top there too! We already use them as our DNS host, so trying out their static site host option, Cloudflare Pages seemed like a no-brainer.
Setting up Wrangler (their CLI tool) was simple. All we needed to do was tell Wrangler to upload our built site artifacts. Upon pushing our site to a test deployment, we discovered in-built redirect rules that conflicted with our desired site configuration.
I prefer URLs with no trailing slash, such as https://electricui.com/features. Gatsby produces folders with an index.html
file for each of our pages.
Netlify would present the file /features/index.html
to both /features/
and /features
without redirects when the "Pretty Urls" option was switched off. We could then state /features
as our canonical URL, and use JS to rewrite the location transparently without having to refetch anything from the network under any potential URL hit. This gave the best of all worlds in terms of SEO and load performance.
Cloudflare Pages has no option to disable redirects or to specify which 'flavor' is desired. The file /features/index.html
is presented primarily at /features/
with a 308 Permanent Redirect from /features
to /features/
.
This was undesirable. Our canonical URLs are all at the non-trailing-slash version of these URLs. While we could rewrite the URL on the client to 'look nice', every link back would trigger a redirect, increasing the time to load the page, going backward on our desire to reduce TTFB and Largest Contentful Paint metrics.
Vercel's hosting explicitly supports a
trailingSlash
setting, where all this can be configured explicitly, which is neat.
How to disable the trailing slash on Cloudflare Pages
With Cloudflare solidified as our choice of host, how can we get our desired canonical URLs?
Cloudflare Pages has the following redirect and serve behavior:
URL | /file | /file/ | /file.html | /folder | /folder/ | /folder/index.html |
---|---|---|---|---|---|---|
behavior | serves file.html | redirect to /file | redirect to /file | redirect to /folder/ | serves folder/index.html | redirect to /folder/ |
It is interesting to note that Cloudflare uses 308 Permanent Redirects instead of 301 Moved Permanently redirects.
Fortunately, that table of behavior does give us the ability to get the canonical URLs we want without redirects. Instead of structuring our html output as /folder/index.html
, we can move the index files up a level and rename them to the name of the folder they were in. Instead of /features/index.html
-> /features.html
.
Gatsby has explicitly decided not to support 'page unfurling', which is the name they give to this transformation. Gatsby Cloud however can handle your trailing slash option of choice, but at the CDN side.
In any case, Linux to the rescue! By running the following in the Gatsby output directory, every index.html
file found (that isn't the root index.html
) will be moved up a level and renamed to match the folder it was just within. It works recursively, and won't delete any folders (even if they're empty after this operation has been completed).
find . -name 'index.html' -mindepth 2 -type f -exec sh -c ' parent="$(dirname "$1")"; mv "$1" "$parent/../$(basename "$parent").html";' find-sh {}
It takes the output folder which looks like this:
/index.html/pricing/index.html/features/index.html/blog/index.html/blog/fast-react-text-updates/index.html
And turns it into this:
/index.html/pricing.html/features.html/blog.html/blog/fast-react-text-updates.html
The resulting redirect behavior by Cloudflare pages is /features/
presents a 308 Permanent Redirect to /features
and /features
presents our features page at our canonical URL. This is probably better for SEO than our previous "200 for every response" behavior, since some search engine scraper might not respect the canonical URL information.
Getting the preview URL out of wrangler
As an aside, the following grep command was used to extract the preview URL from the non-production uploads:
wrangler pages publish ./public --project-name staging | grep -oE "(https:\/\/.+)\b" > ./deploy-url.txt
It's a simple grep, but if you've found this web page searching for "How to disable the trailing slash on Cloudflare Pages", getting this information into your CI solution is probably on your list of tasks.
Core Web Vitals
Given the Core Web Vitals Assessment is what started all this, imagine my delight when Cloudflare pages presented me with frequently updated, time sliceable and filterable Core Web Vitals analytics for every page!
We can trivially filter for mobile and see 90% of our users are having a good experience.
We can pick a time period, percentiles and view outliers; some poor person took 30 seconds to load the features page background video!
The debug view is excellent, showing exactly which element took the most time to render. On our features page, it tends to be either the poster or the video itself for the hero element, which is to be expected. It is unfortunate that some users take 19 seconds to load a 16kb image!
Overall I have been very happy with the switch. The performance has been a significant improvement. The analytics has more detail and filterability for free than Netlify's $9 a month offering. Core Web Vitals information as a first class analytics citizen was a welcome surprise, as far as I know Vercel is the only other platform to support this out of the box. I like that I can explicitly delete deployments, whereas Netlify insists on everything being on the web forever. Having unlimited bandwidth and requests also gives peace of mind. The free price tag is pretty difficult to beat.
Hopefully our Core Web Vitals Assessment updates soon to reflect the change's positive impact.
As an interesting note, the trailing slash on the root (https://electricui.com/) is required by the spec - however browsers tend to hide it! You can read about it and every other piece of the URI scheme in IETF RFC 3986 Section 3.