Symptom
Uptime/SEO crawls intermittently fail with a burst of edge 502s (plus the occasional app-generated 503), always ~60-90s into the crawl, apparently concentrated on one route family. Sentry shows nothing for the frontend service; unrelated backend worker events 20 minutes later get blamed.
What it actually was
A SvelteKit universal load fetched a 640KB CDN JSON via the load's fetch, so the raw response was inlined into every page of the family (823KB HTML/page, 702KB of it data the component never read — the known page-weight defect). Under a concurrency-8 sitemap crawl of 158 such pages, the SSR node held, per in-flight request: the fetch body string + the parsed object graph + the re-escaped inlined JSON + the rendered HTML string. On a 512MB container, Node's default V8 old-space is ~256MB; long-lived instances idle near that ceiling (V8 doesn't compact below a lazily-grown heap), so the crawl's transient allocations tipped it over:
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memoryProcess dies → platform restarts it (~30-60s) → every request routed there during the gap is an edge 502.
Three diagnostic traps
- The failing URL family is whatever was in flight, not the cause. Run 1 failed on family A's pages, run 2 on family B's — same crash. Don't root-cause the URLs in the failure list; root-cause the time window.
- Edge 502s carry no app instance header (
x-ff-instance: nullin our capture). That absence is itself the signal: the app never responded, so look for process death, not app errors. - The crash never reaches Sentry — the process dies before reporting. The only evidence is the platform log stream (V8 GC lines + fatal banner + restart line). A once-a-minute heartbeat log line with
rss_mband event-loop p99 delay is what made the leak-creep and the stall visible (loop_p99_ms: 1399during the crawl; 1006ms mark-compacts in the GC dump).
Fix shape
- Move the load to a server load (
+page.server.ts): raw fetch responses are no longer serialized into HTML, only the trimmed return value ships. 823KB → <100KB per page, and the serialize allocations disappear. - Add a server-side module-level TTL cache for the shared parsed JSON so a crawl parses it once, not N×.
- Raising
--max-old-space-sizeon a small container is a trap: total RSS then exceeds the cgroup and you trade a V8 abort (with GC diagnostics) for a kernel OOM SIGKILL (with none).
Transfer
Applies to any SSR runtime that serializes loader fetches into the response (SvelteKit inlined fetches, Next.js RSC flight payload, Nuxt __NUXT__) running on memory-constrained containers where V8 auto-sizes its heap to ~half of a small RAM budget. The escalation path is: data-inlining page-weight defect × crawler concurrency × near-ceiling baseline heap = process death misread as intermittent 5xx on innocent routes.