# How I made this Astro site build faster and cut its deploy size

> How I made a 4,000-page Astro build phase 25% faster and cut its deploy artifact by 34% using concurrency, shared OG images, and Cloudflare R2.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-03 | Updated: 2026-08-03 | Topics: [Astro](https://flaviocopes.com/tags/astro/) | Canonical: https://flaviocopes.com/optimize-astro-build-deploy-time/

This site generates more than 4,000 static pages.

The warm Astro build took 16.6 seconds. The final `dist` directory was almost 900 MB.

After profiling the build, I cut the Astro phase to about 12.5 seconds and the deploy artifact to about 591 MB.

I didn't change framework. I removed work the build did not need to do.

Here's what changed.

## I measured the whole pipeline first

My production command does more than run Astro:

```json
{
  "build": "npm run courses:downloads:manifest:check && astro build && pagefind --site dist"
}
```

It validates the downloadable course files, builds the site, then creates the Pagefind search index.

Looking only at the total time would hide where the time went. So I measured each phase and inspected the generated files.

The first surprise was the deploy artifact. It contained 2,286 Open Graph images and almost 200 MB of PDF and EPUB files.

Those files pointed at the biggest wins.

## I enabled a little build concurrency

Astro renders one page at a time by default. You can change this using `build.concurrency`:

```js
export default defineConfig({
  build: {
    concurrency: 2,
  },
})
```

This tells Astro to render two pages at the same time.

I also tested a concurrency of 4. It was slightly slower on this project.

More concurrency means more work happening at once, but it also means more memory pressure and overhead. The best number depends on the site and the build machine.

My advice is to benchmark 2 and 4. Don't set it to a large number just because your computer has many CPU cores.

## I stopped sorting the same lessons repeatedly

This site now has 55 free courses. Astro generates a page for every lesson.

The course route built the complete sorted lesson list again for every lesson page. A course with 30 lessons sorted the same list 30 times.

Simplified, the old code worked like this:

```ts
return allLessons.map(lesson => ({
  props: {
    lesson,
    lessons: sortCourseLessons(allLessons, course),
  },
}))
```

I changed it to build each course list once. Again, this is the simplified version:

```ts
const publishedLessonsByCourse = new Map(
  learningCourses.map(course => [
    course.slug,
    sortCourseLessons(allLessons, course),
  ]),
)
```

Every lesson page now reads its list from the map.

This is a small code change, but it removes repeated work from hundreds of routes.

## I stopped generating an image for every post

I previously [generated an Open Graph image for every post](https://flaviocopes.com/generate-og-images-astro/).

Later, I [moved the image cache into a directory Cloudflare preserves](https://flaviocopes.com/cloudflare-pages-build-cache/). That reduced an older build from about 45 seconds cold to 12 seconds warm.

Those two posts describe the previous setup.

The cache worked. But the site kept growing.

Even when Astro reused the cached image, every image was still a route and a file in the deploy artifact. The build contained 2,286 generated social images. Together they used 129 MB.

I asked a simpler question: do more than 2,000 blog posts need a unique social card?

I decided they don't.

Articles now share the site's 42 KB branded image:

```ts
export const PER_POST_OG_IMAGES = false
```

I kept generated cards for courses, tools, ebooks, topics, and important pages. Those pages benefit more from a specific title and description in the image.

The result was:

- 2,286 generated images became 362
- the OG image directory went from 129 MB to 18 MB
- this removed 1,924 generated OG files and about 111 MB from the deploy artifact

The remaining generated cards still use the Cloudflare build cache. I removed the bulk work without throwing away the useful optimization.

Sometimes the best optimization is deleting a feature you previously optimized.

## I moved downloads out of every site deploy

The site offers books and all free courses as PDF and EPUB files.

There were 157 generated PDF and EPUB files, plus a manifest, in Astro's `public` directory. Together they used about 198 MB.

Anything in `public` is copied into `dist`. Cloudflare Pages then receives those files with every deployment, even when none of them changed.

That made no sense for large downloads that rarely change.

I moved them to a top-level `downloads` directory, outside Astro's build, and uploaded them to a Cloudflare R2 bucket. They now use a custom domain:

```text
https://downloads.flaviocopes.com
```

The normal site build no longer copies or deploys them.

I added a separate upload command for the rare moments when the files change:

```bash
npm run downloads:upload
```

The upload script sends four files at a time. It sets the correct content type and download filename, then verifies representative files by size and ETag.

Existing links still work through redirects:

```text
/books/* https://downloads.flaviocopes.com/books/:splat 301
/course-downloads/* https://downloads.flaviocopes.com/course-downloads/:splat 301
```

This change removed about 198 MB from every Pages artifact.

Notice that moving the files outside `public` does not automatically remove them from the repository or its history. Build output, object storage, and repository size are three separate problems.

## I removed repeated text from the search index

Every course lesson repeats its breadcrumb, course navigation, and download call to action.

Pagefind only needs the lesson content. I marked the repeated sections with `data-pagefind-ignore`:

```html
<aside class="course-contents" data-pagefind-ignore>
  <!-- course navigation -->
</aside>
```

The search index went from about 22 MB to 21 MB.

This was mainly a search-quality improvement. It removes repeated navigation from results. I would not claim it as a major build-time win.

## I kept a quick local build command

The complete production build should keep its validation and search indexing.

But I don't need both steps every time I want to check whether Astro can render the site.

So I added:

```json
{
  "build:quick": "astro build"
}
```

I can now run this during development:

```bash
npm run build:quick
```

Cloudflare still runs the complete production command. The quick command only shortens my local feedback loop.

## The result

Here are the useful before and after numbers from warm local builds:

| | Before | After |
|---|---:|---:|
| Astro build | 16.6s | about 12.5s |
| Static route generation | 6.5s | 4.5s |
| Deploy artifact | about 897 MB | about 591 MB |
| Generated OG images | 2,286 | 362 |
| OG image directory | 129 MB | 18 MB |

The Astro phase became about 25% faster. The deploy artifact became about 34% smaller.

The 25% improvement refers to `astro build`, not the complete production command.

I don't have comparable Cloudflare deployment timings, so I won't claim a precise production speedup. But Cloudflare now has about 306 MB less to handle on every deploy.

The main lesson is simple: profile the build, then ask whether every operation and every generated file belongs there.

Concurrency helped. Reusing computed data helped.

But the largest improvements came from not generating and deploying things the site did not need.
