Making Cloudflare Docs build 243% faster with a 5 line change
We recently launched the newest iteration of our developer documentation, migrating from Hugo to Astro and Starlight. In our evaluation, we used the biggest Starlight site we can find - Astro’s own - as our reference for build times. They built 4,634 pages in 4 minutes and 20 seconds. Considering our Hugo and Vite setup took approximately 4 minutes to build 4,004 pages, that made us confident that the move from Hugo to Astro wouldn’t cause a noticeable slowdown during build times.
After we finished our migration, we saw build times closer to 10 minutes despite our sites being relatively similar in size. Looking at Astro’s own build logs, each page took around 10ms to build. We were seeing a lot closer to 80ms per page.
Why were our pages so much slower?
The usual answers or possibilities range from:
- Components making fetch() calls
- Double, or even triple rendering the page
- Parsing large files in components or custom pages
These would typically point to specific pages being slow, but we were seeing a pretty uniform increase in per page build times as we migrated over more content. You might think “it just gets slower linearly”, but Astro has more pages than us. There’s only one path, profiling!
Flamegraphing with 0x
0x
is a command-line utility that makes flamegraphing Node super easy. We wanted to flamegraph npx astro build , so we did npm i 0x and npx 0x ./node_modules/.bin/astro build . What do we see?
The elephant in the room here is generateRouteData
, that represents 43.3% of the time and a lot of that is within the getSidebar
calls.
Profiling with Node —inspect & Chrome DevTools
Node’s --inspect
flag can be hooked up to Chrome’s DevTools, and allows us to capture performance profiles. Running node --inspect ./node_modules/.bin/astro build
, and opening DevTools in Chrome, you’ll see a Node icon in the top left of the panel which will open a new window.
Clicking on Select JavaScript VM instance will let you select the currently running Astro build, and then we can capture a profile!
Once again, we see that we’re spending all of our time per page in generateRouteData
and getSidebar
.
generateRouteData
This function creates all of the metadata that a Starlight page needs. The title, sidebar entries, table of contents entries and other meta info. Since we’re spending a lot of time in getSidebar , you might look at that call and notice the first argument is url.pathname . The sidebar is generated on each page, in order to highlight the current page you’re on in the sidebar. Let’s dig into that function.
export function generateRouteData({ props, url,}: { props: PageProps; url: URL;}): StarlightRouteData { const { entry, locale, lang } = props; const sidebar = getSidebar(url.pathname, locale); const siteTitle = getSiteTitle(lang); return { ...props, siteTitle, siteTitleHref: getSiteTitleHref(locale), sidebar, hasSidebar: entry.data.template !== 'splash', pagination: getPrevNextLinks(sidebar, config.pagination, entry.data), toc: getToC(props), lastUpdated: getLastUpdated(props), editUrl: getEditUrl(props), labels: useTranslations(locale).all(), };}
getSidebar
export function getSidebar(pathname: string, locale: string | undefined): SidebarEntry[] { const routes = getLocaleRoutes(locale); if (config.sidebar) { return config.sidebar.map((group) => configItemToEntry(group, pathname, locale, routes)); } else { const tree = treeify(routes, locale || ''); return sidebarFromDir(tree, pathname, locale, false); }}
These functions seem pretty innocuous, generating a sidebar for each group specified in our Astro config, but the trap lies in how we structure our sidebar. We’re generating every group but in the context of the current page, we only need one of them. You might start to see the problem here!
When you’re on https://developers.cloudflare.com/workers/, you only see the sidebar for pages inside the Workers folder.
With us building 4,004 pages, and a lot of products having less than 200 pages, that’s a lot of entries we’re processing that we’re going to throw away anyways.
How can we fix this?
If we only care about one group from config.sidebar
on a given page, we can change the logic to only process the group
whose label
matches our current product (pathname.split("/")[1]
).
Using patch-package
patch-package
makes patching NodeJS packages very simple, and we’ll use that to test our theory. After installing patch-package with npm i patch-package
, we can go into node_modules
and make changes to @astrojs/starlight/utils/navigation.ts
. After making these changes, we will run npx patch-package @astrojs/starlight
to create the patch and then npx patch-package
to apply it.
diff --git a/node_modules/@astrojs/starlight/utils/navigation.ts b/node_modules/@astrojs/starlight/utils/navigation.tsindex 43369be..58c4d91 100644--- a/node_modules/@astrojs/starlight/utils/navigation.ts+++ b/node_modules/@astrojs/starlight/utils/navigation.ts@@ -336,8 +336,12 @@ function sidebarFromDir( /** Get the sidebar for the current page. */ export function getSidebar(pathname: string, locale: string | undefined): SidebarEntry[] { const routes = getLocaleRoutes(locale);+ const currentSection = pathname.split("/")[1]; if (config.sidebar) {- return config.sidebar.map((group) => configItemToEntry(group, pathname, locale, routes));+ return config.sidebar.flatMap((group) => {+ if (group.label !== currentSection) return [];+ return configItemToEntry(group, pathname, locale, routes);+ } ); } else { const tree = treeify(routes, locale || ''); return sidebarFromDir(tree, pathname, locale, false);
What does 0x show after making this change?
If you look hard enough, you might still be able to see the generateRouteData
calls. They now only represent 4% of the stack, more than a 10x reduction from the previous flamegraph!
How much did this speed up builds?
How does from 11 minutes and 35 seconds to 4 minutes and 45 seconds sound? Sounds great? Awesome!
22:36:02 ├─ /aegis/about/connection-forwarding/index.html (+82ms)22:36:02 ├─ /aegis/about/index.html (+82ms)22:36:02 ├─ /aegis/about/ips-allocation/index.html (+78ms)22:36:02 ├─ /aegis/configuration-options/access-cni/index.html (+82ms)22:36:02 ├─ /aegis/configuration-options/data-localization/index.html (+77ms)22:36:02 ├─ /aegis/configuration-options/index.html (+86ms)22:36:02 ├─ /aegis/configuration-options/load-balancing/index.html (+82ms)22:36:03 ├─ /aegis/configuration-options/network-firewall/index.html (+82ms)22:36:03 ├─ /aegis/configuration-options/workers/index.html (+77ms)22:36:03 ├─ /aegis/index.html (+96ms)
22:35:43 ├─ /aegis/about/connection-forwarding/index.html (+8ms)22:35:43 ├─ /aegis/about/index.html (+6ms)22:35:43 ├─ /aegis/about/ips-allocation/index.html (+12ms)22:35:43 ├─ /aegis/configuration-options/access-cni/index.html (+6ms)22:35:43 ├─ /aegis/configuration-options/data-localization/index.html (+6ms)22:35:43 ├─ /aegis/configuration-options/index.html (+16ms)22:35:43 ├─ /aegis/configuration-options/load-balancing/index.html (+6ms)22:35:43 ├─ /aegis/configuration-options/network-firewall/index.html (+6ms)22:35:43 ├─ /aegis/configuration-options/workers/index.html (+5ms)22:35:43 ├─ /aegis/index.html (+14ms)
The Astro team quickly made this redundant
Shortly after we did this, the Starlight team quickly made changes to improve the performance of the sidebar generation and we haven’t needed to patch it since!