Edge Routing and Page Weight
Reduce static page weight, remove unnecessary edge work, and simplify the routing stack — without breaking any legacy SEO URL inherited from the super.so site.
This spec exists because a local AbortError in the Netlify Deno edge runtime turned out to be a symptom of a much larger problem: pattern pages are 3.6 MB, and every page view buffers the whole body inside an edge function. The routing design itself is sound and constraint-driven; the problems are payload size and one ordering bug.
Measured baseline
All figures measured on 2026-08-29 from a committed out/ build.
Correction to the first pass of this spec: the page originally profiled (patterns/169-…, 3.63 MB) is an outlier, not a representative page. Measured across all 347 pattern pages, the distribution was:
| Statistic | Size |
|---|---|
| min | 0.83 MB |
| median | 0.86 MB |
| max | 3.63 MB |
| pages over 2 MB | 8 of 347 |
| total pattern HTML | 321 MB |
So there were two distinct problems, not one:
- Every pattern page carried ~0.86 MB, of which 94% was inline script. On a median page, 190 whole-catalog cover URLs accounted for 294 KB (36% of the document) — Notion cover values are signed S3 URLs averaging 1.58 KB each. This was the dominant cost, at 347× scale.
- 8 pages carried an additional ~2.7 MB because they embed an inline ALL_PATTERNS gallery, which legitimately serializes a record per sibling.
Other baseline figures:
| Artifact | Size | Note |
|---|---|---|
public/.notion-cache/ | 79 MB (373 files) | copied to out/ at 91 MB and served publicly |
public/_redirects | 740 rules | free-tier cap is 1000 |
netlify/redirect-data.json | 67 KB | 369 slugs + 369 ids, loaded once per edge isolate |
Composition of the 1.48 MB cached recordMap for the outlier page (1009 blocks):
| Portion | Count | Bytes | Share |
|---|---|---|---|
Sibling page blocks under ALL_PATTERNS_DB | 342 | 704,348 | 48% |
image blocks | 152 | — | — |
| The page’s own direct children | 10 | — | — |
collection_query (full 343-id gallery result set) | — | 13,885 | 1% |
Note that most caches are small (the median pattern’s cache holds 3 blocks), so recordMap pruning was worth 8.8% across the corpus while map scoping was worth 66% per page. The bulk was never the recordMap.
Findings that answer the open questions
Why are the pages heavy? Not markdown negotiation.
markdown-negotiation is a read-only transform at request time and contributes nothing to build output.
The dominant cause was unscoped whole-catalog props. src/app/patterns/[id]/page.tsx passed searchArtifacts.coverMap, tagsMap, descriptionMap, and notionVersionMap — each keyed by all ~369 patterns — straight into NotionPageRenderer, a "use client" component. Every entry was therefore serialized into the RSC payload of every prerendered page, even though the client can only ever look up ids linked from that page. Cover values are the expensive part at ~1.58 KB of AWS signature query string each.
A secondary cause is the same serialization boundary applied to the recordMap:
src/app/patterns/[id]/page.tsx:231passes the fullrecordMaptoNotionPageRenderer.src/components/NotionPageRenderer.tsx:1is"use client".
Any prop crossing into a client component must be serialized into the RSC flight payload, so the entire 1.48 MB recordMap is embedded in every prerendered page — once in the HTML and again in the .txt sidecar. react-notion-x is client-only, so this is structural, not accidental.
The recordMap is also larger than it needs to be. scripts/build-content-cache.mjs:147-152 fetches with fetchCollections: true, which resolves the parent ALL_PATTERNS_DB gallery query and pulls in 342 sibling pattern pages — 48% of the file — that this page does not display as page content. signFileUrls: true adds ~2 KB of AWS query string per image across 152 image blocks. Nothing is trimmed between disk and the client prop; the only sanitization (NotionPageRenderer.tsx:371-400) runs client-side, after serialization, and dropped 0 of 1009 blocks.
Is markdown negotiation still needed now that MCP is mature? Yes — keep it.
The MCP surface and markdown negotiation do not overlap. All 8 MCP tools (netlify/edge-functions/mcp.ts:247-395) return JSON built from search-index.json, which contains no Notion block content. get_pattern (mcp.ts:595-603) returns index metadata only. llms.txt (2.1 KB) and llms-full.txt (1.5 KB) are navigation manifests with no corpus. The per-route .txt files in out/ are RSC flight payloads, not markdown, and are useless to an agent.
So curl -H 'Accept: text/markdown' /patterns/<slug> is currently the only way for any client to obtain a pattern’s body as text, and it serves agents that cannot open a JSON-RPC MCP session. public/llms.txt:41 advertises it as a live capability. Dropping it would remove a real, non-duplicated capability and lower the agent-readiness posture tracked in Agent Readiness.
The correct action is to make it cheap, not to remove it. It currently downloads and discards a 3.63 MB document to emit a few KB of markdown, so it benefits more than anything else from the page-weight work below.
Can /all-patterns/* be retired? Yes, with one naming hazard.
Only three references concern the public URL: scripts/build-redirects.mjs:125 (the generator), public/_redirects:8 (its generated output), and a redundant block in src/proxy.ts:199-201 that is almost certainly dead under output: export.
Hazard: 17 other occurrences of the string all-patterns are the menuGroups identifier defined at site.config.mjs:62, which is the primary key for the ALL_PATTERNS_DB Notion database across the entire content pipeline (search index, metadata cache, content cache, components cache, /patterns route, and parentDatabaseId on pattern pages). Scope every edit to occurrences containing a slash (/all-patterns/). A bare find-and-replace on all-patterns breaks the build.
Non-negotiable invariant: Notion ID URLs keep working
Legacy Notion-ID URLs must continue to resolve for both spellings (32-char hex and dashed UUID). Two independent mechanisms serve them, and both must stay green:
| URL shape | Served by | Cost |
|---|---|---|
/{32-hex-or-uuid} (bare, top level) | redirector.ts:133-137 via idMap | zero redirect rules |
/patterns/{id}, /ux-patterns/{id}, /ui-patterns/{id} | 738 rules in _redirects | 738 of the 1000-rule budget |
The folder-scoped paths are in the redirector’s excludedPath, so the function never sees them — the _redirects rules are the only thing serving those, despite the function’s header comment claiming otherwise. No task in this spec may reduce coverage of either shape, and W6 adds tests to enforce that.
Budget note: the 738 rules grow by two per published pattern. At 369 patterns there is headroom for roughly 126 more before scripts/build-redirects.mjs:141 errors on the 1000-rule cap. That decision is scoped in W5.
Results (implemented 2026-08-29)
| Metric | Before | After | Change |
|---|---|---|---|
| Median pattern page | 0.86 MB | 0.29 MB | −66% |
| Min pattern page | 0.83 MB | 0.27 MB | −67% |
| Max pattern page (gallery) | 3.63 MB | 2.87 MB | −21% |
| Total pattern HTML (347 pages) | 321 MB | ~101 MB | −220 MB |
Total out/ HTML | — | 181 MB | — |
Raw Notion cache in out/ | 91 MB | 0 | removed |
| Redirect rules | 740 | 739 | −1 |
| Production edge response buffering | every compressed response | none | removed |
Local GET / | 18.2 s + AbortError | 2.6–4.7 s, HTTP 200 | see W7 |
Local GET /patterns/<slug> | 7.7 s | 1.1–2.3 s | see W7 |
Verification: npm run build succeeds; npx tsc --noEmit clean; ESLint clean on all changed files; npm run validate:redirects reports 739 rules with 369 bare /<id> and 738 folder-scoped rules covered; npm run validate:recordmap-prune passes all invariants across 373 caches; npm run validate:page-weight passes with the 5 gallery pages auto-detected and exempted.
W1 — Fix the passthrough ordering bug
Highest value for the lowest risk. passthroughResponse buffers the entire response body before checking whether the bytes are valid, so it buffers on every compressed response in production — including every ~3.5 MB pattern page — to work around a bug that only occurs in the local CLI.
- Read
netlify/edge-functions/lib/passthrough-response.ts:10-20and confirm the cheap exit only covers uncompressed responses. - Gate the buffering on a local-dev signal (e.g.
Netlify-Devheader or aNETLIFY_DEV/NETLIFY_LOCALenv check) so production returns the response untouched with noarrayBuffer()call. - Add a short comment recording that the workaround is local-only and why.
- Verify locally that
/chatbot.jsand RSC navigations still render (the originalERR_CONTENT_DECODING_FAILEDsymptoms) — both return 200 with correct content types and decode cleanly. - Confirm the
AbortErrorno longer reproduces — see W7, which turned out to be the actual cause.
W2 — Cut page weight
The main event. Do these in order; each is independently shippable and measurable.
W2a — Stop over-fetching the collection.
- Determine whether the 342 sibling
pageblocks are actually required at render time.NotionCollection.tsx:406,529usesparentDatabaseId, and the in-page “Related patterns” section is built from DOM anchors (NotionPageRenderer.tsx:1354), so sibling titles may be resolved from the recordMap. Verify before removing — do not assume this is free. - If unused: add a build-time prune step that drops blocks whose
parent_idis the ALL_PATTERNS_DB collection id and are not the page itself, plus thecollection_querygallery result set. Expected saving ~700 KB per page (48%). - If used: retain them. Verified they are used, by two paths — page-mention decorators (react-notion-x emits nothing when the target block is absent, silently deleting the “Related patterns” list) and the inline gallery on 5 pages. The prune is therefore reference-aware: it keeps the page subtree, its ancestors, and every referenced sibling, and skips pages with an inline collection view entirely.
scripts/validate-recordmap-prune.mjsasserts both invariants across all 373 caches. - Re-measure a representative page and record before/after in this spec.
W2b — Stop inlining what is already a static file.
- Approach changed after measurement. A client-fetched recordMap artifact was not needed: profiling showed the recordMap was not the bulk on typical pages (median cache is 3 blocks). The whole-catalog prop maps were.
src/lib/search-artifacts-scope.mjsnarrowscoverMap/tagsMap/descriptionMap/notionVersionMapto the ids a page can reference — itself, patterns linked from the body by page mention or by href, and the recent-patterns rail — andsrc/app/patterns/[id]/page.tsxpasses the scoped maps. This preserves SSR entirely, so no page became a client shell. - Keep server-rendered HTML intact — confirmed. The
notion-page-wrapper,notion-page, and property-table markup are still server-rendered, and re-running themarkdown-negotiationtransform over the new output yields the same content as before. - Optional follow-up:
/patternsand the category pages still pass full maps because they render every card. They are within budget, but scoping them to their own visible set would cutpatterns.html(3.69 MB) and the ~2.6–2.8 MB category pages. - Re-measure both the
.htmland the.txtsidecar.
W2c — Address the signed image URLs.
- Confirm whether
signFileUrls: trueURLs are still valid at runtime. They are not, and it does not matter. The 190 signed cover URLs insearch-index.jsonwere signed20260808T151139ZwithX-Amz-Expires=3600, so their signatures expired on 2026-08-08. Fetching one anyway returns302→200 image/png, because the values arehttps://www.notion.so/image/<encoded-s3-url>?…and Notion’s image proxy re-signs server-side. So images are not broken, but the ~1.5 KB of AWS signature per URL is inert payload. - Optional follow-up: store cover values without the
X-Amz-*query string, cutting each from ~1.86 KB to ~200 bytes. This would shrink the pages that still carry many covers (/patterns, category pages). Needs a deliberate decision plus verification that Notion’s proxy accepts the bare form — do not assume it does.
W2d — Stop publishing the raw Notion cache.
public/.notion-cache/(79 MB, 373 files) is copied intoout/at 91 MB and served publicly. Decide whether that is intended.- Audit a sample for anything non-public:
spaceId,collectionschemas, andnotion_userrecords. (notion_userwas empty in the file sampled; confirm across all 373.) - If unintended, move the cache outside
public/and read it from the repo root at build time, or strip it fromout/during the existing post-build step alongsidescripts/strip-components-from-out.mjs. Note that W2b may deliberately want a trimmed subset to remain fetchable — reconcile the two.
W3 — Retire /all-patterns/*
- Remove the rule from
scripts/build-redirects.mjs:125. - Regenerate
public/_redirectsand confirm the count drops to 747 and no other rule changed. - Remove the dead block at
src/proxy.ts:199-201after confirmingsrc/proxy.tsdoes not execute underoutput: export. - Update the stale comment at
redirector.ts:20-21that lists/all-patterns/*as handled elsewhere. - Do not touch the
menuGroupsid"all-patterns"(site.config.mjs:62) or any of its 17 lookups. - Accept and note the consequence: surviving inbound
/all-patterns/...links will 404 instead of redirecting.
W4 — Make markdown negotiation cheaper (keep the capability)
- Return
context.next()directly for non-markdown requests instead of routing throughpassthroughResponse(markdown-negotiation.ts:82-83) — the transform path reads the body anyway, so only the pass-through case pays for nothing. - Re-verify
Accept: text/markdownoutput after W2 — re-ran the transform against the rebuilt HTML and the extracted content is unchanged, because scoping touched only client props, not server-rendered markup. public/llms.txt:41stays accurate — the capability is unchanged, so no edit needed.- Leave the in-file
config.patharray as-is — it is what keeps the toml to two[[edge_functions]]entries and prevents the Denospawn EBADFclass of failure.
W5 — Routing scope and rule budget
- Decide on the redirect-rule budget. Option A: leave as-is and accept a ceiling around 126 more patterns. Option B: move the 738 folder+ID rules into the redirector’s
idMap(the code atredirector.ts:133-137already handles the shape; it is unreachable only because/patterns/*is excluded), freeing 738 rules at the cost of an edge invocation on every pattern page view. Option B is only sane after W1 makes pass-through cheap. - Fix the header comment at
redirector.ts:10-12, which documents a “Folder + Notion ID” case thatexcludedPathmakes unreachable. - After W1, evaluate whether the reactive
excludedPathadditions for large static assets (search-index.json,og-*.png,metadata-cache.json,pablo.png) are still needed, or whether the list can shrink back to genuine routing concerns. - Consider inverting the redirector’s
pathfrom/*-minus-denylist to an explicit allowlist of shapes it handles. Higher risk — sequence last, and only with W6 tests in place.
W7 — Serve the Notion cache in dev (the real AbortError cause)
Added after W1–W6, when the AbortError reproduced on the homepage despite the buffering fix. The dev log gave it away:
GET / 200 in 18.2s (next.js: 1966ms, proxy.ts: 67ms, application-code: 16.2s)
16.2 s was spent in application code, not in the edge function. Before this change getBuildCachedNotionPage read the disk cache only when NEXT_PHASE === "phase-production-build", so every next dev render performed a live notionClient.getPage() with fetchCollections: true and fetchMissingBlocks: true (src/lib/notion.ts:714-727), on a 10 s timeout with up to 3 retries. The homepage’s Notion page has inline databases, making that fetch enormous.
The edge AbortError was therefore a symptom: the Netlify edge invocation deadline expired while waiting for context.next() to return a response that was 16–18 s away. Buffering was a red herring for this particular failure.
- Read the disk cache whenever it exists, in any phase, not just during production builds.
- Keep the production build strict: a cache miss still returns null rather than falling back to the network, which is what prevents Notion 429 bursts.
- Fall back to a live fetch on cache miss in dev, with a warning.
- Add
NOTION_DEV_LIVE=1to force live fetches when Notion edits are not yet in the cache. - Side benefit: dev now exercises the same pruned recordMap as production, so the W2a prune is actually covered by local testing.
Measured locally after the change:
| Request | Before | After |
|---|---|---|
GET / | 18.2 s (AbortError body) | 2.6–4.7 s, HTTP 200 |
GET /patterns/<slug> | 7.7 s | 1.1–2.3 s |
| Pattern page bytes served | 815 KB | 322 KB |
Caveat to document for contributors: dev now renders from public/.notion-cache, so Notion edits do not appear until npm run build:content re-pulls them, or NOTION_DEV_LIVE=1 is set.
W6 — Regression guards
Without these, W2 and W3 can silently break legacy SEO.
- Add a test or build assertion covering the invariant above: for a sample of patterns, both ID spellings resolve for the bare
/{id}form and all three folder-scoped forms. - Add a page-weight budget check to the build that fails or warns when any page in
out/exceeds a threshold, so 3.6 MB pages cannot silently return. - Add an assertion that
_redirectsstays under the 1000-rule cap (extend the existing warning atscripts/build-redirects.mjs:139-142into a hard failure). - Verify
scripts/validate-redirects.mjscovers the ID cases — extended withvalidateNotionIdCoverage(), which asserts everyidMapkey is a bare 32-hex id (so the edge lookup cannot miss) and that both spellings of every folder-scoped id have a_redirectsrule.
Acceptance criteria
- A representative pattern page in
out/is materially smaller than 3.63 MB, with the target set after W2a measurement. - No production request buffers a full response body inside an edge function.
- Every Notion-ID URL shape in the invariant table still resolves, proven by an automated check rather than manual spot checks.
Accept: text/markdownstill returns usable markdown for pattern pages./all-patterns/*is gone from_redirectsand the content pipeline still builds — search index, metadata cache, and content cache all regenerate cleanly.npm run buildandnpm run lintpass; the bookmarks drawer opens without anAbortErrorlocally.
Verification commands
# page weight
ls -la out/patterns/169-delete-content-forever-youtube-studio.html
find out -name '*.html' -exec ls -la {} \; | sort -k5 -rn | head -10
# redirect budget and rule shapes
rg -v '^#|^$' public/_redirects | wc -l
rg -c 'all-patterns' public/_redirects
# markdown negotiation still works (local)
curl -s -H 'Accept: text/markdown' http://localhost:8888/patterns/<slug> | head -40
# public share path as an anonymous visitor
node scripts/diagnose-public-share.mjs <share-token>
Risks
- Pruning the recordMap breaks related patterns or embedded collection views. The sibling blocks may be load-bearing for title resolution. W2a gates on verification for this reason; do not prune on the assumption that unrendered means unused.
- W2b regresses SEO or markdown output if content moves from server-rendered HTML into a client fetch. The markdown transform parses the static HTML, so anything that becomes client-only disappears from markdown and from crawlers.
- Find-and-replace on
all-patternsbreaks the build pipeline via themenuGroupsid. Scope to/all-patterns/. - Removing the
/all-patterns/*redirect drops live inbound links. Low volume expected, but irreversible for anyone still linking to the super.so URLs. - Signed Notion S3 URLs may already be expiring in production. W2c may surface a pre-existing image-freshness bug rather than a pure size win.
Out of scope
- The
next devheap growth (7.7 GB after ~19.5 h). Separate process, separate cause; unrelated to the edge runtime. - The stale
bookmarks_public_select_sharedRLS policy blocking anonymous reads on shared libraries. Tracked separately and diagnosed withscripts/diagnose-public-share.mjs. - Moving
/l/{token}back onto Edge. It works as a Node Function and matches the OG and sitemap functions; theEBADFconstraint that forced the move was later solved by collapsing the toml declarations.