astrodevelopment

What's affecting Astro build times

While working with bigger Astro websites, I started wondering what affects build times the most. So I ran a series of benchmarks on content size, images, content sources, Astro versions, runtimes, concurrency, incremental builds and parallel prerendering.

What's affecting Astro build times

While working with some bigger Astro websites, I started wondering how I can reduce the build times and what affects them the most. To do so, I decided to run a series of benchmarks to see what had the biggest impact.

Methodology

I ran Astro 7.4.4 and 6.4.8 on an Apple M4 Pro (12 cores) with Node v24.18.0. Builds were cold, with caches cleared before every run. Each variant got one discarded warm-up build, and I report the median of the timed runs. On a laptop, small differences are noise. The same 10k-post cold build came out between 3.86 s and 4.16 s in different tables (about 8%), so I treat anything under ~10% as no real difference.

Keep the hardware in mind, too. A 12-core M4 Pro is generous, and a 2–4 vCPU CI runner may show smaller gains from concurrency and parallel prerendering, and hit memory limits sooner. Treat the absolute numbers as a guide and rerun the tests that matter on your own setup.

If you want to find out where your build spends its time before changing anything, Astro 7.4 added an astro build --timings flag. It prints a breakdown of build phases, Markdown processing, page rendering and images, and Astro calls it a good first step before trying flags like parallel prerendering or incremental builds.

Size matters

That’s a pretty obvious take, but the larger the website, the longer the build.

For simple Markdown posts, it took:

Build time by number of posts Simple Markdown posts, median
  • 100 0.68 s
  • 1,000 1.04 s
  • 10,000 3.93 s
  • 50,000 16.91 s

Hover or focus a row for details.

Data table
Number of postsMedian
1000.68 s
1,0001.04 s
10,0003.93 s
50,00016.91 s

While this naturally affects the build length, having 500 times more posts makes the build only ~25 times slower. The fixed startup cost dominates small sites, and the marginal cost is about 0.32 ms per extra post. Memory grows faster than time, though: peak usage went from 444 MB at 100 posts to 5.5 GB at 50,000.

But the number of posts is only one part. What’s also important is what’s inside the post. More images and MDX with components can affect the build time a lot.

Images

Heavy images are one of the main pain points of the internet. Luckily, Astro has some great tools for optimizing them. Using the <Image> or <Picture> components allows you to:

  • change the image format
  • resize it
  • add a loading strategy

So, let’s see what happens when we try to convert base images using the <Picture> component:

Build time by image format and size Converted with the Picture component, median
  • 2 MP → JPEG 0.86 s
  • 2 MP → WebP 1.82 s
  • 2 MP → AVIF 11.53 s
  • 8 MP → JPEG 1.16 s
  • 8 MP → WebP 3.97 s
  • 8 MP → AVIF 34.43 s
  • 24 MP → JPEG 2.00 s
  • 24 MP → WebP 23.48 s
  • 24 MP → AVIF 74.28 s

Hover or focus a row for details.

Data table
VariantMedian
2 MP → JPEG0.86 s
2 MP → WebP1.82 s
2 MP → AVIF11.53 s
8 MP → JPEG1.16 s
8 MP → WebP3.97 s
8 MP → AVIF34.43 s
24 MP → JPEG2.00 s
24 MP → WebP23.48 s
24 MP → AVIF74.28 s

Based on this, there are some clear findings:

  • While AVIF produces the smallest images, it affects the build time heavily. At 24 MP it took 74.28 s, compared to 2.00 s for JPEG. The smallest case I tested (0.5 MP to JPEG) took 0.60 s, which makes 24 MP AVIF about 125 times slower. The 24 MP WebP result (23.48 s) looks like an outlier: it’s nearly 6 times slower than 8 MP for 3 times the pixels, and about 12 times slower than 24 MP JPEG.
  • Astro has an image cache that reduces that time greatly. In my test, converting an 8 MP image to WebP took 3.91 s with a cold cache and 0.48 s with a warm one (8 times faster). Per the Astro docs, processed images are stored in node_modules/.astro by default (configurable with cacheDir), and “by preserving the cache directory between builds, processed assets are reused”. Sadly, many CI/CD platforms delete everything on each run, so make sure yours keeps (or caches) that directory.
  • If your website is very image-heavy, outsourcing this to a CDN might be a better option.
  • Dropping raw images in might not be the best idea, especially when using AVIF. It’s worth pre-optimizing them a bit.

MD vs MDX vs Headless CMS

The content source is also a very important aspect that affects the build speed. To test this, I fetched 500 posts as Markdown, MDX, or from a mock API (fetch inside pages vs a content collection loader), at 20 ms and 100 ms API latency.

Build time by content source 500 posts, median
  • Markdown collection 0.90 s
  • MDX collection 1.64 s
  • API, fetch in pages, 20 ms 26.37 s
  • API, content collection loader, 20 ms 0.76 s
  • API, fetch in pages, 100 ms 107 s
  • API, content collection loader, 100 ms 0.93 s

Hover or focus a row for details.

Data table
VariantMedian
Markdown collection0.90 s
MDX collection1.64 s
API, fetch in pages, 20 ms26.37 s
API, content collection loader, 20 ms0.76 s
API, fetch in pages, 100 ms107 s
API, content collection loader, 100 ms0.93 s

The gap comes down to request count. Fetching inside pages made 1,002 requests, while the loader made 2. Build time with fetch-in-pages is roughly requests × latency (1,002 × 20 ms ≈ 20 s, 1,002 × 100 ms ≈ 100 s), which is close to what I measured. With a loader, latency barely registers. At matching latency, the loader was about 35 times faster at 20 ms and about 115 times faster at 100 ms. It even matched local Markdown (0.76 s and 0.93 s vs 0.90 s), probably because my mock API returns ready-to-use content, so there is no Markdown processing to do. MDX was 1.83 times slower than Markdown and used more memory (709 MB vs 498 MB).

It’s worth mentioning that MDX results may vary based on what sits inside the components. It doesn’t change the fact that if you’re aiming for quick builds, you should either use Markdown files or a properly built API with content collections.

Using the latest Astro version

It’s always worth following what the Astro developers are cooking. For example, Astro 7 improved build times by a lot.

Astro 6.4.8 vs 7.4.4 Synthetic test, median
  • Astro 6.4.8 18.92 s
  • Astro 7.4.4 4.09 s

Hover or focus a row for details.

Data table
VariantMedian
Astro 6.4.818.92 s
Astro 7.4.44.09 s

In my synthetic test, using Astro 7 made the build over 4 times faster.

A 4.6x jump is bigger than what Astro promises. In the Astro 7 release post, the team reports builds that are 15–61% faster, thanks to Sätteri together with Vite 8 and its new Rolldown bundler. My test site is pure Markdown, and Astro 7 replaces the default Markdown and MDX pipeline with Sätteri, a Rust-based processor, so that’s where it gains the most. The new Rust .astro compiler on its own improved docs.astro.build by only about 6%, because Markdown processing and bundling usually dominate build time.

An independent test points the same way. Bitdoze rebuilt a 743-page site on Astro 6.1.9 and 7.0.4 and went from 103 s to 46.92 s (2.2x), with Cloudflare Pages deploys dropping from about 7 minutes to about 2.

Astro’s own benchmarks, run on an M4 Pro with 48 GB of memory, show the range on real websites:

Build time before and after Astro 7 Reported by Astro for real websites

Hover or focus a row for details.

Data table
WebsiteBeforeAfter
docs.astro.build (~6,313 pages)114.54s73.53s
astro.build (~308 pages)62.70s24.24s
biomejs.dev (~6,488 pages)176.39s149.90s
developers.cloudflare.com (8,431 pages)386.89s261.94s
tauri.app (7,117 pages)86.12s55.33s
aspire.dev (13,275 pages)385.84s326.11s

Node vs Bun

The runtime environment can affect the build time, too:

Node vs Bun: 10k Markdown posts Median
  • 10k md posts, Node 3.86 s
  • 10k md posts, Bun 3.69 s

Hover or focus a row for details.

Data table
VariantMedian
10k md posts, Node3.86 s
10k md posts, Bun3.69 s

And for images:

Node vs Bun: image conversion Median
  • 16 x 2MP images to WebP, Node 1.81 s
  • 16 x 2MP images to WebP, Bun 1.30 s

Hover or focus a row for details.

Data table
VariantMedian
16 x 2MP images to WebP, Node1.81 s
16 x 2MP images to WebP, Bun1.30 s

On Markdown, the 4% gain is within the noise I mentioned earlier, and Bun used more memory (2.3 GB vs 1.6 GB peak). The real gain was in image encoding, where Bun was 1.39 times faster.

Build concurrency

Concurrency (build.concurrency, available since Astro 4.16) is one of the settings that will probably bring little, apart from some unique cases. Even the official documentation mentions:

In most cases, you should not change the default value of 1.

With simple Markdown files, the gain is small in absolute terms (4.13 s down to 2.90 s at best, about 30%), but it isn’t zero:

Concurrency: 10k Markdown posts Median
  • 10k md posts, concurrency 1 4.13 s
  • 10k md posts, concurrency 2 3.40 s
  • 10k md posts, concurrency 4 3.23 s
  • 10k md posts, concurrency 8 2.94 s
  • 10k md posts, concurrency 12 2.90 s

Hover or focus a row for details.

Data table
VariantMedian
10k md posts, concurrency 14.13 s
10k md posts, concurrency 23.40 s
10k md posts, concurrency 43.23 s
10k md posts, concurrency 82.94 s
10k md posts, concurrency 122.90 s

Where it shines is when we’re using fetch on pages, because the build spends most of its time waiting on the network. The number of requests stays the same (1,002), but they overlap. Keep in mind that this is fetching inside pages, which is the slow pattern from the previous sections. Here, concurrency is a mitigation for when you can’t switch to a loader:

Concurrency: API fetch in pages Median
  • API fetch-in-pages (500 posts), concurrency 1 25.13 s
  • API fetch-in-pages (500 posts), concurrency 8 4.18 s

Hover or focus a row for details.

Data table
VariantMedian
API fetch-in-pages (500 posts), concurrency 125.13 s
API fetch-in-pages (500 posts), concurrency 84.18 s

Incremental builds

Incremental builds are one of those features that, in some cases, can do wonders, and sometimes just add problems to your stack with no real gains.

They arrived as an experimental feature in Astro 7.2 (experimental.incrementalBuild). A page is skipped only if its route returns a cacheKey from getStaticPaths() and the hash of its module graph hasn’t changed. For content collections, the docs suggest using entry.digest. Paths without a cacheKey are always rendered. The docs also list a few catches:

  • the cache lives in cacheDir (node_modules/.astro/ by default), so in CI it has to be restored before astro build
  • it is disabled when build.concurrency is greater than 1, so you can’t combine it with the previous section
  • changes to your middleware don’t invalidate cached pages (use astro build --force to ignore the cache)

First, I tested simple and small Markdown pages:

Incremental builds: small Markdown pages 10k posts, median
  • 10k posts, cold build (incremental off) 4.16 s
  • 10k posts, warm rebuild, incremental off 3.81 s
  • 10k posts, incremental on, first (cold) build 5.49 s
  • 10k posts, incremental on, rebuild with no changes 4.17 s
  • 10k posts, incremental on, 1% edited 4.70 s
  • 10k posts, incremental on, 10% edited 4.99 s

Hover or focus a row for details.

Data table
VariantMedian
10k posts, cold build (incremental off)4.16 s
10k posts, warm rebuild, incremental off3.81 s
10k posts, incremental on, first (cold) build5.49 s
10k posts, incremental on, rebuild with no changes4.17 s
10k posts, incremental on, 1% edited4.70 s
10k posts, incremental on, 10% edited4.99 s

In this case, the incremental cache didn’t help, and the first build was slower. That’s why I decided to test it on heavier Markdown pages:

Incremental builds: heavier Markdown pages 10k posts, 2 ms render work per page, median
  • 10k posts, 2 ms render work per page, cold build 25.10 s
  • 10k posts, 2 ms per page, warm rebuild, incremental off 24.87 s
  • 10k posts, 2 ms per page, incremental on, rebuild with no changes 4.12 s
  • 10k posts, 2 ms per page, incremental on, 1% edited 4.87 s
  • 10k posts, 2 ms per page, incremental on, 10% edited 6.97 s

Hover or focus a row for details.

Data table
VariantMedian
10k posts, 2 ms render work per page, cold build25.10 s
10k posts, 2 ms per page, warm rebuild, incremental off24.87 s
10k posts, 2 ms per page, incremental on, rebuild with no changes4.12 s
10k posts, 2 ms per page, incremental on, 1% edited4.87 s
10k posts, 2 ms per page, incremental on, 10% edited6.97 s

Here, we can finally see how incremental builds can improve your flow a lot.

Where they shine is again fetching inside pages, because the build spends most of its time waiting on the network. Skipped pages don’t make their requests, so the cache cuts the API calls too (2 requests with no changes, 12 with 1% of pages edited, 102 with 10%). Keep in mind that this is the slow pattern from the previous sections, so incremental builds are a mitigation for when you can’t switch to a loader:

Incremental builds: API fetch in pages 500 posts, 20 ms latency, median
  • API fetch-in-pages (500 posts, 20 ms), cold build 25.60 s
  • API fetch-in-pages (500 posts, 20 ms), incremental on, rebuild with no changes 0.59 s
  • API fetch-in-pages (500 posts, 20 ms), incremental on, 1% edited 0.85 s
  • API fetch-in-pages (500 posts, 20 ms), incremental on, 10% edited 3.21 s

Hover or focus a row for details.

Data table
VariantMedian
API fetch-in-pages (500 posts, 20 ms), cold build25.60 s
API fetch-in-pages (500 posts, 20 ms), incremental on, rebuild with no changes0.59 s
API fetch-in-pages (500 posts, 20 ms), incremental on, 1% edited0.85 s
API fetch-in-pages (500 posts, 20 ms), incremental on, 10% edited3.21 s

Incremental builds also depend entirely on a persisted cache. I made the same point about images, but here it matters even more: if your CI pipeline wipes node_modules/.astro (or cacheDir) before each build, every build is a cold one and you get no benefit at all, apart from the slower first build.

Experimental parallel prerendering

This is something that Astro introduced in 7.4 (experimental.parallelPrerender). It renders static pages in Node.js worker threads, which allows Astro to use more than just one core of your CPU. By default, it uses all available cores but one. On paper, it should improve the builds by a lot, but the first benchmark showed the opposite. When running the test on small Markdown pages, the gains are close to nothing:

Parallel prerendering: small Markdown pages Median
  • off (default) 3.92 s
  • on (default workers) 3.78 s
  • workers: 2 4.38 s
  • workers: 4 4.03 s
  • workers: 8 3.71 s

Hover or focus a row for details.

Data table
VariantMedian
off (default)3.92 s
on (default workers)3.78 s
workers: 24.38 s
workers: 44.03 s
workers: 83.71 s

But like with incremental builds, it shines when we have many slow pages. This time I used the API with 20 ms latency:

Parallel prerendering: API fetch in pages 500 pages, 20 ms latency, median
  • off (default), 500 API pages 24.42 s
  • on (default workers), 500 API pages 3.18 s
  • workers: 2, 500 API pages 13.13 s
  • workers: 4, 500 API pages 7.46 s

Hover or focus a row for details.

Data table
VariantMedian
off (default), 500 API pages24.42 s
on (default workers), 500 API pages3.18 s
workers: 2, 500 API pages13.13 s
workers: 4, 500 API pages7.46 s

Two more things to keep in mind. Each worker adds to the memory your build uses. And per the docs, pages rendered in parallel don’t share module-level variables, and getStaticPaths() props that can’t be serialized (functions, class instances) can’t be rendered in parallel.

So what does it mean?

Astro gives us a lot of ways to optimize our build. But as you can see from this series of benchmarks, many of those are scenario-based. You’ll need different tools if you’re using Markdown compared to the ones you’ll use when working with a headless CMS API.

A rough order of what to try:

  • Measure first with astro build --timings.
  • Fetching data? Use a content collection loader instead of fetching inside pages. In my tests, that was the biggest difference of all (about 115 times at 100 ms latency).
  • Lots of images? Mind the output format and the source size, and keep node_modules/.astro between builds.
  • Expensive pages you can’t fix? Incremental builds, parallel prerendering and higher concurrency are mitigations, not upgrades. Pick one of incremental builds or higher concurrency, since incremental builds are disabled above build.concurrency 1 (I tested them separately, not combined). Incremental builds also need a persisted cacheDir in CI.
  • Mostly cheap text pages? Upgrade Astro, and leave the rest at the defaults.

The result that surprised me the most is that none of the flags I tested came close to one structural choice. Moving the data fetching from the pages into a content collection loader had the biggest impact of everything I measured, with no flags or special settings. So before you reach for a setting, check how your build gets its data.

Enjoyed this post?

New posts straight to your inbox. No spam.

Subscribe

Share your thoughts