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.
When the fetch lives in a +layout.ts that many routes inherit, and it returns a collection scoped to a route param (a user's items, a project's files):
- 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.
CORRECTION to an earlier revision of this post
An earlier revision claimed "two loaders hitting the same URL dedupe into one inlined block." That is false. Verified in @sveltejs/kit@2.69.1:
src/runtime/server/page/render.js:366maps 1:1 over thefetchedarray. No dedup on emission.src/runtime/server/page/load_data.js:271create_universal_fetchwrapsevent.fetchand pushes tofetched. No response cache.
So N identical fetches produce N inlined blocks and N network calls. The corollary is the useful part: the set of data-sveltekit-fetched blocks is an exact census of which loaders actually ran. If you expect a parent layout's fetch and its block is absent, that layout's load did not execute. See the sibling lesson on @-suffixed layout resets.
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}")Cross-check that URL list against the loaders you believe run on the route. Mismatches are the finding.
Then drill into the winning block per-field:
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 shape by measuring pages across owners with different collection sizes; you should get a clean line whose slope is bytes-per-collection-item. Real numbers from one case: 1 item = 132 KB, 4 = 172 KB, 21 = 728 KB, 177 = 1.96 MB. Slope ~10.4 KB/item over a ~130 KB baseline, crossing 1 MB at ~84 items.
Fixes
- Never fetch a collection in a
loadwhose route doesn't render it. Push it down into the+page.tsthat needs it. - If the caller only needs an aggregate (a count badge is the classic), return the aggregate from an endpoint already being called, not the collection.
- Give list endpoints a list-shaped DTO. Detail-shaped objects reused in collections are the usual multiplier. In one case a nested
field_signaturesarray was 86% of the collection payload and no list UI read it. - Check what the consumer actually renders. The offending fetch pulled 177 objects into a component whose
max_views = 3, and a sibling endpoint already returned exactly 3, so it rendered nothing at all. - 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. Wire-size monitoring will not catch this.
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 for ~45 s after the crawl stopped. 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 (layout server components serializing into the RSC flight payload) and Nuxt (useAsyncData in a layout landing in __NUXT__).