Your Astro build isn't slow — it's O(n²)

astroperformancessgjavascript

I have a static site with 22,818 pages. One pre-rendered HTML page per practice question. The first full build was on track to take about 24 hours.

It now takes three to four minutes. The fix was four lines, and the bug is one almost every large Astro site hits eventually — because at 50 pages it is invisible and at 20,000 it is fatal.

The shape of the problem

Astro page frontmatter runs once per generated page. That sentence is in the docs and it is easy to read past. Here is what it means for a dynamic route:

---
// src/pages/[...slug].astro
const questions = await getCollection('questions');
const question = questions.find(q => q.data.slug === Astro.params.slug);
---

Two separate O(n) operations, each executed once per page:

  1. getCollection() re-reads and re-parses the entire collection from disk
  2. .find() linearly scans it

With n pages, that is O(n²) total work. At n = 22,818 it measured about 2.5 seconds per page. Multiply it out: roughly 15 hours of pure collection loading, before Astro renders a single byte of HTML.

At 100 pages the same code costs a couple of seconds total. Nobody notices. The code is correct, idiomatic, and lifted straight from the docs. It just doesn’t survive contact with scale.

The fix

Collections are immutable for the lifetime of a build. So load once, at module scope, and let every page await the same already-resolved promise:

// src/lib/content.ts
import { getCollection, type CollectionEntry } from 'astro:content';

type Question = CollectionEntry<'questions'>;

// Module scope: evaluated once per build, not once per page.
// This caches the PROMISE, not the awaited value — so the 22,817 pages that
// arrive while the first load is still in flight all await the same one
// rather than each kicking off their own.
let questionsPromise: Promise<Question[]> | undefined;
let bySlug: Map<string, Question> | undefined;

export function getQuestions(): Promise<Question[]> {
  // Sort here, once. Sorting in frontmatter is the same O(n log n) paid n times.
  // Sorting by slug also makes builds reproducible.
  questionsPromise ??= getCollection('questions').then((qs) =>
    qs.sort((a, b) => a.data.slug.localeCompare(b.data.slug)),
  );
  return questionsPromise;
}

export async function getQuestionBySlug(slug: string) {
  if (!bySlug) {
    bySlug = new Map((await getQuestions()).map((q) => [q.data.slug, q]));
  }
  return bySlug.get(slug);
}

??= is doing real work there: it assigns only if the variable is still undefined, so the second through 22,818th call are a single property read.

Three things, and they are independent — you need both:

  • Cache the promise, not the value. Caching the awaited array still lets a hundred concurrent page builds each start their own getCollection() before the first one resolves. Caching the promise means the first caller pays and everyone else joins it.
  • Build an index, don’t scan. The Map turns a per-page O(n) .find() into O(1). Build it lazily on first use so you don’t pay for it in builds that never look a question up by slug.
  • Sort once, inside the cached promise. A .sort() in frontmatter is O(n log n) paid n times, and it is easy to miss because it hides behind the getCollection() you were already blaming.

O(n²) → O(n). 24 hours → 3–4 minutes.

How to know if this is you

Build a subset and check whether the time scales linearly.

# 100 pages
time npm run build   # with the collection filtered to 100 entries
# 1000 pages
time npm run build   # ...and to 1000

10× the pages should be roughly 10× the time. If it is closer to 100×, you have a per-page O(n) somewhere in frontmatter. The usual suspects, in order:

  1. await getCollection() in a dynamic route’s frontmatter
  2. .find() / .filter() over the full collection to resolve one entry
  3. a sort of the whole collection to compute “next” and “previous” links
  4. import.meta.glob() eagerly resolving every match, per page

All four have the same fix: hoist it to module scope, and index instead of scanning.

The part that isn’t obvious

The reason this bug survives review is that the profile lies to you. Every individual page build looks fine — 2.5 seconds is slow but not alarming, and nothing in the output says “you did this 22,818 times.” You only see it in the total, and by then you have been staring at a progress bar for six hours and are inclined to blame the machine.

If your SSG build time is superlinear in page count, it is almost never the renderer. It is something in per-page setup that should have been per-build.


This came out of building navyduck.com — 22,818 pre-rendered certification practice questions, static Astro, self-hosted. The generator is boring; keeping the build under four minutes was the interesting part.

← All articles