ADR-012: Blog Performance Optimizations
Status
Section titled “Status”Accepted
Note: This ADR documents the implementation of patterns defined in ADR 020: Page Performance Patterns. Refer to ADR 020 for comprehensive performance guidelines.
Context
Section titled “Context”The initial blog implementation had several performance bottlenecks that would scale poorly as content grows:
- Redundant Sorting: Each blog post page fetched and sorted ALL posts independently (O(n log n) per page)
- Double Rendering:
post.render()was called twice—once for content, once for headings - Implicit Image Decoding: Cover images didn’t explicitly set
decoding="async" - Per-Page Metadata Calculation: Reading time calculated from raw markdown on every page
For a blog with 100 posts, this meant:
- 100 separate sort operations (instead of 1)
- 200 markdown parse operations (instead of 100)
- Unnecessary blocking on image decode
Decision
Section titled “Decision”Implement build-time performance optimizations following Astro’s static generation best practices:
1. Compute Navigation Once in getStaticPaths
Section titled “1. Compute Navigation Once in getStaticPaths”Before:
// In [slug].astro page componentconst allPosts = (await getCollection("blog")).sort(...);const currentIndex = allPosts.findIndex((p) => p.slug === post.slug);const prevPost = currentIndex > 0 ? allPosts[currentIndex - 1] : null;const nextPost = currentIndex < allPosts.length - 1 ? allPosts[currentIndex + 1] : null;After:
// In getStaticPaths (runs once)export async function getStaticPaths() { const posts = await getCollection("blog"); const sortedPosts = posts.sort(...);
return sortedPosts.map((post, index) => ({ params: { slug: post.slug }, props: { post, prevPost: index > 0 ? sortedPosts[index - 1] : null, nextPost: index < sortedPosts.length - 1 ? sortedPosts[index + 1] : null, }, }));}Impact: Reduces O(n²) to O(n log n) for entire build
2. Render Markdown Once Per Page
Section titled “2. Render Markdown Once Per Page”Before:
// In [slug].astroconst { Content } = await post.render();
// In BlogLayout.astroconst { headings } = await post.render(); // DUPLICATE!After:
// In [slug].astroconst { Content, headings } = await post.render();
// Pass to layout<BlogLayout post={post} headings={headings}> <Content /></BlogLayout>
// In BlogLayout.astro - receive as propconst { post, headings } = Astro.props;Impact: Eliminates 50% of markdown parsing operations
3. Explicit Async Image Decoding
Section titled “3. Explicit Async Image Decoding”Before:
<Image src={cover} loading="eager" />After:
<Image src={cover} loading="eager" decoding="async" />Impact: Prevents image decode from blocking main thread (LCP improvement)
4. Reading Time Calculation (Deferred)
Section titled “4. Reading Time Calculation (Deferred)”Current: formatPostMetadata(date, post.body, updated) processes raw markdown per page
Decision: Keep as-is for now because:
- Static builds = one-time cost
- Only becomes bottleneck at 1000+ posts
- Precomputing would require content schema changes or build hooks
Future: If blog exceeds 500 posts, consider:
- Precomputing in content collection config
- Caching in build hook
- Storing in frontmatter
Rationale
Section titled “Rationale”Why This Approach?
Section titled “Why This Approach?”- Leverage Static Generation: Astro builds pages once—optimize the build, not runtime
- Minimal Code Changes: Works within existing architecture
- Measurable Impact: Each optimization has clear performance benefit
- Scalability: Handles 100s of posts efficiently; 1000s with future optimizations
Performance Gains
Section titled “Performance Gains”| Metric | Before | After | Improvement |
|---|---|---|---|
| Sort operations (100 posts) | 100 | 1 | 99% reduction |
| Markdown parses (100 posts) | 200 | 100 | 50% reduction |
| Image decode blocking | Implicit | Async | LCP improvement |
Why Not Alternative Approaches?
Section titled “Why Not Alternative Approaches?”Alternative 1: Client-side navigation
// Fetch prev/next via API❌ Breaks static generation, adds runtime overhead
Alternative 2: Precompute everything in content schema
// Store metadata in frontmatter❌ Over-engineering for current scale, harder to maintain
Alternative 3: Cache sorted posts globally
// Use module-level cache❌ Unnecessary complexity when getStaticPaths already runs once
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- ✅ Build Performance: 50-99% reduction in redundant operations
- ✅ Scalability: Handles 100s of posts efficiently
- ✅ Lighthouse Scores: Async decoding improves LCP
- ✅ Maintainability: Cleaner separation of concerns (data prep in getStaticPaths)
- ✅ Zero Runtime Cost: All optimizations are build-time only
Neutral
Section titled “Neutral”- Slightly more complex
getStaticPaths(but clearer intent) headingsmust be passed as prop (explicit dependency)
Negative
Section titled “Negative”- None identified for current scale (< 500 posts)
Compliance
Section titled “Compliance”- User Rules: ✅ Follows minimal, focused edits principle
- User Rules: ✅ Leverages Astro’s static generation patterns
- User Rules: ✅ Optimizes build performance without runtime overhead
- Performance: ✅ Targets 95+ Lighthouse scores
- Scalability: ✅ Handles growth to 100s of posts
Implementation Checklist
Section titled “Implementation Checklist”- Move sorting to
getStaticPathsin/src/pages/blog/[slug].astro - Pass
prevPostandnextPostas props - Render markdown once, pass
headingsto layout - Update
BlogLayout.astroto acceptheadingsprop - Add explicit
decoding="async"to cover image - Monitor build times as content grows
- Consider precomputing reading time if blog exceeds 500 posts
Related Files
Section titled “Related Files”src/pages/blog/[slug].astro- Primary optimizationssrc/pages/blog/index.astro- Uses centralized blog utilitiessrc/layouts/BlogLayout.astro- Receives headings as propsrc/utils/blog.ts- Centralized blog post queries and sorting (eliminates duplication)src/utils/formatDate.ts- Reading time calculation (future optimization)
Future Considerations
Section titled “Future Considerations”If Blog Exceeds 500 Posts
Section titled “If Blog Exceeds 500 Posts”-
Precompute Reading Time:
// In content config or build hookconst readingTime = estimateReadingTime(post.body); -
Pagination for Blog Index:
// Already implemented in /blog/index.astro -
Incremental Builds:
- Explore Astro’s experimental incremental static regeneration
- Only rebuild changed posts
Monitoring
Section titled “Monitoring”Track build times in CI/CD:
pnpm run build --verbose# Monitor: "Generating static routes" durationReferences
Section titled “References”- Astro getStaticPaths
- Astro Content Collections
- Image Decoding Performance
- Build-time vs Runtime Optimization Principles
Enforcement
Section titled “Enforcement”- Not machine-checkable: the compute-in-
getStaticPathspattern is a review concern; aggregate regressions surface via the JS bundle budget and the Lighthouse workflow rather than a static check. - Graduation log: (empty at creation; entries added when a check changes status)
Date: 2025-10-01 (footer backfilled 2026-07-05 from git history; this record predates the footer convention)
Participants: Template maintainers
Outcome: Accepted