Symptom
Ahrefs (or any SEO crawler) flags "page size too large" (>1 MB HTML) across a whole route subtree. The rendered markup looks small; view-source shows a multi-megabyte page.
Cause
SvelteKit's universal load runs on the server during SSR, and every fetch() it makes is cached into the HTML as:
<script type="application/json" data-sveltekit-fetched data-url="https://api.example.com/...">{"status":200,...,"body":"<escaped JSON>"}</script>This exists so client-side hydration doesn't re-issue the request. It means the full, unfiltered API response body ships in the HTML, even if the component renders three fields from it.
The trap: a load in +layout.ts runs for every child route. If that layout fetches a collection scoped to a route param (a user's items, a project's files), then:
- page weight is linear in collection size
- the number of affected pages is also linear in collection size
- total crawlable bytes are quadratic
It degrades silently as data grows, then crosses the crawler threshold all at once.
Diagnosis
Attribute the bytes before touching code:
import re, json, gzip, urllib.request
req = urllib.request.Request(url, headers={"Accept-Encoding":"gzip"})
html = gzip.decompress(urllib.request.urlopen(req).read()).decode()
blocks = re.findall(
r'<script type="application/json" data-sveltekit-fetched data-url="([^"]+)"[^>]*>(.*?)</script>',
html, re.S)
print(f"total html: {len(html)}")
for u, b in sorted(blocks, key=lambda x: -len(x[1])):
print(f"{len(b):>9} {u}")Then drill into the winning block per-field to find the fat property:
from collections import defaultdict
body = json.loads(json.loads(raw)["body"]) # note the double decode
cost = defaultdict(int)
for item in body["items"]:
for k, v in item.items():
cost[k] += len(json.dumps(v, separators=(",", ":")))Confirm the quadratic shape by measuring pages across owners with different collection sizes; you should get a clean line whose slope is bytes-per-collection-item.
Fixes
- Never fetch a collection in a layout
loadthat a leaf route doesn't render. Push it down into the+page.tsthat needs it. - If the layout only needs an aggregate (a count badge is the classic), return the aggregate from an endpoint the layout already calls, not the collection.
- Give list endpoints a list-shaped DTO. Detail-shaped objects reused in collections are the usual multiplier — here one nested
field_signaturesarray was 86% of the collection payload and no list UI read it. - Check for redundant fetches across the layout chain. Two loaders hitting the same URL dedupe into one inlined block, so it hides in review, but it also means deleting only one of them saves nothing.
- Compression hides this from humans but not from crawlers: 1.96 MB of HTML gzipped to 311 KB, so the page felt fine over the wire while Ahrefs measured it uncompressed.
Secondary effect
Oversized SSR pages amplify crawl load: every crawler hit forces the backend to serialize the whole collection again. A 12-concurrent sitemap crawl produced 230/501 502s and left the origin degraded. Crawl errors are themselves an SEO-health input, so one root cause shows up as two separate score penalties.
Applies to
SvelteKit 2 / @sveltejs/kit. Same class of bug exists in Next.js App Router (loading/layout server components serializing into the RSC flight payload) and Nuxt (useAsyncData in a layout landing in __NUXT__).