Corrective Action: Sanity CDN Quota Exhaustion and Broken Reference Crash
Date: 2026-03-25 Category: Verification Failure (build cache) + API Behavior Assumption (null handling) Impact: 500 error on production article page; Vercel build blocked by Sanity CDN quota exhaustion; Chris required to upgrade Sanity plan Resolution Time: ~40 minutes (15:00 - 15:40 CDT)
Incident
What Happened
A visitor-facing 500 error appeared on valuefirstteam.com/media/articles/you-dont-have-to-add-insight-you-have-to-draw-it-out. Investigation traced the crash to RelatedContent.astro line 155, where .map() was called on aiSuggestedLinks without filtering nulls from broken Sanity references. The null filter fix was committed and pushed, but the Vercel build then failed with plan_limit_reached - API CDN Requests quota limit reached. A full audit of Sanity query patterns revealed the website was making approximately 2,100 CDN requests per build due to undeduped navigation queries and an existing build cache (build-cache.ts) that was exported but never wired into the build pipeline.
Timeline
| Time | Event |
|---|---|
| ~15:00 CDT | Chris reports 500 error on article page |
| ~15:05 | Root cause identified: null references in RelatedContent.astro aiSuggestedLinks |
| ~15:10 | Fix committed (filter nulls), pushed to main |
| ~15:15 | Vercel build fails: Sanity CDN quota exhausted |
| ~15:20 | Build log confirms plan_limit_reached at prerender step |
| ~15:25 | Full audit of Sanity query patterns reveals ~2,100 requests/build |
| ~15:30 | Discovery: build-cache.ts exists but initSanityCache() never called |
| ~15:35 | Chris upgrades Sanity plan to Growth (pay-as-you-go overage) |
| ~15:40 | All optimizations implemented, committed, pushed |
Root Cause
Two distinct failure mechanisms combined to produce a compounding incident.
Issue 1: Broken Sanity Reference Crash
RelatedContent.astro line 155 used .map() on aiSuggestedLinks without filtering nulls. When Sanity dereferences a broken reference using the []-> operator, it returns null in the array rather than omitting the entry. Spreading null and accessing null._type throws a runtime error, producing a 500 on the article page.
Issue 2: Build Cache Never Wired Up
src/lib/sanity/build-cache.ts was created with initSanityCache(), fetchWithCache(), cachedData accessors, and 13 pre-defined queries. It was exported from src/lib/sanity/index.ts. But no code ever called initSanityCache(). The cache was dead infrastructure -- built, documented, exported, but never plugged into the build pipeline.
Meanwhile, navigation components (ContentMegaMenu, ToolsMegaMenu) and shared components (FeaturedTools, RelatedContent) each made independent sanityClient.fetch() calls that executed on every page during build. With approximately 333 static pages, this multiplied to approximately 2,100 CDN requests per build:
ContentMegaMenu.astro: 2 Sanity queries per page (~666 requests/build)ToolsMegaMenu.astro: 1 Sanity query per page (~333 requests/build)FeaturedTools.astro: Duplicated the same tools queryRelatedContent.astro: Ran all 6 tag-based queries regardless of content type- No query deduplication across navigation components
Category: Verification Failure + API Behavior Assumption
The build cache represents a verification failure: infrastructure was created, exported, and appeared complete, but the critical activation step was never executed or verified. The null reference crash represents an API behavior assumption: code assumed Sanity's []-> dereference operator would only return valid objects, when in fact it returns null for broken references.
Fix Applied
Immediate Resolution
Two commits addressed both issues: a null filter for the crash, and full build cache activation with query optimization for the quota exhaustion.
Code/Configuration Changes
| File | Change |
|---|---|
apps/website/astro.config.mjs |
Added sanityCacheIntegration() -- Astro integration hook that calls initSanityCache() at astro:build:start |
apps/website/src/lib/sanity/build-cache.ts |
Added latest-episode and latest-article queries to cache; added getLatestEpisode() and getLatestArticle() accessors |
apps/website/src/components/navigation/ContentMegaMenu.astro |
Replaced 2 direct Sanity queries with cachedData.getLatestEpisode() / cachedData.getLatestArticle() |
apps/website/src/components/megamenu/ToolsMegaMenu.astro |
Replaced direct sanityClient.fetch(allToolsQuery) with cachedData.getTools() |
apps/website/src/components/home/FeaturedTools.astro |
Replaced direct fetch with cachedData.getTools() |
apps/website/src/pages/apps/index.astro |
Replaced direct fetch with cachedData.getTools() |
apps/website/src/pages/apps/[slug].astro |
getStaticPaths uses cachedData.getTools() instead of direct fetch |
apps/website/src/components/content/RelatedContent.astro |
(a) Added .filter((item) => item != null) before .map() on aiSuggestedLinks. (b) Made all 6 tag-based queries conditional on contentType -- episode pages skip episode query, article pages skip article query, etc. |
Configuration Change
Chris upgraded Sanity plan from Free to Growth ($15/seat/month) with pay-as-you-go overage at $1/250K requests, preventing future hard build failures on quota limits.
Verification
- TypeScript check passed (
npx tsc --noEmit-- zero new errors) - Commits:
7f81ac17(null filter fix),77d9ab07(full optimization) - Both pushed to main, Vercel deploying
- Estimated savings:
1,400 fewer Sanity CDN requests per build (60% reduction, from ~2,100 down to ~700)
Prevention Measures
Rules Added
| Layer | File | Rule |
|---|---|---|
| Critical Lessons | MEMORY.md |
Build infrastructure must be verified end-to-end. Exporting a function is not the same as calling it. The Sanity build cache existed for months but was never wired up -- saving ~1,400 requests/build once connected. |
| Critical Lessons | MEMORY.md |
ALWAYS filter nulls when dereferencing Sanity arrays with []->. Broken references return null in the array. Use .filter((item) => item != null) before .map(). |
Detection Triggers
If you catch yourself creating build-time infrastructure (caches, preloaders, integrations) without verifying the entry point is called during the actual build, you are repeating the build-cache pattern. Exported code is not executed code. After creating any build-time optimization:
- Verify the initialization function is called (not just exported)
- Add a log line or build step that confirms execution
- Run a build and confirm the optimization is active in the output
If you catch yourself using .map() on any Sanity array that was populated via []-> dereference without a null filter, you are assuming Sanity returns clean arrays. It does not. Broken references produce nulls.
Lessons
Infrastructure that exists but is not connected is worse than infrastructure that does not exist -- it creates false confidence. The build cache was documented, exported, and even had accessor functions, but nobody verified that the entry point was actually called. "The code exists" is not the same as "the code runs." Verify the full chain from trigger to effect.
Separately, external API return shapes must be treated defensively. Sanity's []-> operator is a dereference, not a guaranteed resolution. Any reference can break (deleted document, changed slug, draft-only state), and when it does, the array contains null. Defensive filtering is not optional -- it is the correct way to handle dereferenced arrays.
Related Incidents
- Portal Documents filter on
listing_content_type === 'document'-- same pattern: infrastructure exists but invisible misconfiguration causes silent failure. Created correctly, rendered nowhere. - HubSpot null values vs property existence -- same pattern: assuming data shape without verification. Null does not mean absent; it means empty.
- CRLF line endings in .env files -- same pattern: infrastructure looks correct but has an invisible defect that causes silent downstream failure.