Skip to content

perf(core): eagerly prefetch site-global layout data on public pages#1509

Merged
ascorbic merged 3 commits into
mainfrom
perf/layout-prefetch
Jun 18, 2026
Merged

perf(core): eagerly prefetch site-global layout data on public pages#1509
ascorbic merged 3 commits into
mainfrom
perf/layout-prefetch

Conversation

@ascorbic

@ascorbic ascorbic commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Speeds up public page loads on remote databases (D1, Durable Objects) by eagerly warming site-global layout data at the start of the request, so the shared layout's per-component reads (menus, widget areas, taxonomy term lists, site settings) overlap into roughly one round trip instead of executing serially as each component renders.

On a public HTML page, the layout pulls the same chrome on every request, but each is awaited inside a separately-rendered Astro component, so they serialize into N sequential DB round trips. This fires them concurrently up front (via the real helpers, so the cache keys/values are identical) and the components then hit a warm requestCached entry.

  • D1 / Durable Objects: the round trips overlap (pipelined) into ~1 wall-clock RTT; on a coalescing backend they additionally batch.
  • Node / local SQLite: gated off entirely (the absence of a request-scoped db means the cost — synchronous, microsecond queries — would never pay off).

Fully transparent — no template changes. Stacked conceptually on #1498 (the per-request cache reuse it relies on is now in main).

Implementation notes:

  • Fired via after() so it runs immediately (warming the render) but the surplus warm-up (chrome a given page doesn't render) is kept alive past the response by waitUntil rather than orphaning request I/O on workerd.
  • Gated to the client's preferred text/html type, so feeds / sitemaps / JSON endpoints don't pay for chrome they never render.

Honest caveats (please weigh before merging)

  • The win is wall-clock TTFB, not query count. It does not lower db.count; on remote backends it lowers effective round trips by overlapping them. It actually raises db.count slightly: it over-fetches (loads all chrome, since middleware can't know which a template renders) and adds one SELECT DISTINCT menu-name discovery query.
  • It leans on the adapter's commit() leaving the request-scoped db usable during the waitUntil window — true for the D1 and DO adapters (bookmark-persist, no close), but core can't prove it.
  • I'd like to measure the actual TTFB delta on a remote backend (the DO/D1 perf fixtures) before merge to confirm the overlap win exceeds the over-fetch cost. Opening it now for review; happy to gate further or trim the over-fetch if the numbers are marginal.

An adversarial review pass found and fixed a real bug before this PR: the original fire-and-forget form leaked surplus request I/O past the response (orphaned-I/O error on workerd) — now resolved via after()/waitUntil. ALS-context preservation across the deferral was verified.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change) — added a prefetch cache-warm test; core suite green
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation — n/a, no UI strings
  • I have added a changeset (emdash patch)
  • New features link to an approved Discussion — n/a, perf refactor

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: claude-opus-4-8 (OpenCode)

Screenshots / test output

Added menu-request-cache.test.ts case proving prefetchLayoutData() warms menus so the layout's getMenu calls become pure cache hits (zero further queries). Core suite (266 files) passes; typecheck + lint clean.


Try this PR

Open a fresh playground →

A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.

Tracks perf/layout-prefetch. Updated automatically when the playground redeploys.

ascorbic added 2 commits June 16, 2026 16:16
On the anonymous public-page path (HTML navigations, request-scoped/remote
backends only), warm menus, widget areas, taxonomy term lists and site settings
concurrently up front via the real helpers, so the layout's per-component reads
overlap into ~one wall-clock round trip and hit a warm request cache instead of
serializing. Template-transparent (warms the exact keys helpers already use).

Fired via after() so it runs immediately (warming the render) but the surplus
warm-up is kept alive past the response by waitUntil rather than orphaning I/O
on workerd. Gated to the client's preferred text/html type so feeds/JSON don't
pay for chrome they never render.

Helps remote backends (D1, Durable Objects) where round trips dominate; a no-op
on synchronous local SQLite (gated out by the absence of a request-scoped db).
@changeset-bot

changeset-bot Bot commented Jun 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4af6d6b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 16, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache 4af6d6b Jun 16 2026, 04:43 PM

@pkg-pr-new

pkg-pr-new Bot commented Jun 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1509

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1509

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1509

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1509

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1509

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1509

emdash

npm i https://pkg.pr.new/emdash@1509

create-emdash

npm i https://pkg.pr.new/create-emdash@1509

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1509

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1509

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1509

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1509

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1509

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1509

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1509

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1509

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1509

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1509

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1509

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1509

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1509

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1509

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1509

commit: 4af6d6b

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 16, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs 4af6d6b Jun 16 2026, 04:43 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 16, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground 4af6d6b Jun 16 2026, 04:43 PM

# Conflicts:
#	packages/core/src/astro/middleware.ts
@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jun 17, 2026
@ascorbic ascorbic merged commit ed921d8 into main Jun 18, 2026
45 checks passed
@ascorbic ascorbic deleted the perf/layout-prefetch branch June 18, 2026 06:09
@emdashbot emdashbot Bot mentioned this pull request Jun 18, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approach judgment

This is a sound, transparent performance optimization that fits EmDash's architecture: it reuses the existing requestCached store, after()/waitUntil deferral, and ALS request context; it's gated to anonymous public HTML navigations on request-scoped (remote) backends; and it warms the real helpers so cache keys/value shapes are guaranteed identical — no template changes. I have no objection to the approach.

One thing deserves the maintainer's conscious sign-off rather than being buried: this is a perf PR whose wall-clock benefit is unmeasured (the author flags this honestly) and it intentionally raises db.count — it over-fetches chrome the page may not render and adds a per-request SELECT DISTINCT menu-name discovery query. AGENTS.md says "Fewer [queries] is always right; more needs a conversation." The author has been transparent about exactly this trade-off, so I'm not surfacing a hidden cost — just confirming it's a real call to make, not a free win. The mechanism itself is safe and reversible.

What I checked

  • Cache-key/value compatibility for all four warmed types: menus (menu:${name}:${locale}), settings (siteSettings, which also satisfies getSiteSetting(key) via peekRequestCache), widget areas (widget-area:${name}), taxonomies (taxonomy-defs:${locale} / taxonomy-terms:${name}:${locale}). All match because the prefetch invokes the real helpers with no explicit locale, so they resolve the request-context locale exactly as the layout does. Widget-area shapes match too — getWidgetAreas() and getWidgetArea(name) build identical WidgetArea objects (same columns, same sort_order ordering, same rowToWidget), and setRequestCacheEntry's if (cache.has(key)) return guard means any render call that raced ahead wins its own in-flight promise, so ordering can't cause a stale/mismatched value.
  • ALS preservation across the deferral: after() schedules prefetchLayoutData via Promise.resolve().then(fn) while ctx is active inside runWithContext, so fn (and its await continuations) run with ctxgetDb()/getRequestContext() resolve to the request-scoped db. The surplus queries that outlive the response stay within ctx's scope and reference it, so the WeakMap-keyed request cache isn't collected early.
  • commit() safety: for the anonymous path commit() returns immediately (no bookmark for anon), and the D1/DO adapters' destroy() are no-ops, so the scoped Kysely stays usable through the waitUntil window — the assumption the author flagged does hold for both remote adapters.
  • Gating: prefetch runs only inside if (anonScoped) (D1-sessions/DO only; local SQLite returns null and skips it), itself nested under the anonymous, non-/_emdash, non-sitemap, no-edit/preview-cookie fast path. The acceptsHtml leading-media-range parse correctly excludes feeds (which lead with application/rss+xml) and is safe for empty/*/* headers ("".split(",",1)[0] is "", and the ! is sound since split always yields ≥1 element).
  • Error handling: Promise.allSettled + outer try/catch mean a prefetch failure never surfaces to the request; requestCached deletes its entry on rejection, so a failed prefetch query is re-issued on demand by the render — no new failure mode.
  • Tests: the new menu-request-cache.test.ts case uses a real in-memory SQLite DB with real migrations and asserts zero post-prefetch menu queries — legitimate, not a mock returning what it claims. Existing middleware tests aren't disturbed: their Requests carry no text/html Accept (verified new Request(url).headers.get("accept") is null), so acceptsHtml is false and the prefetch path is skipped, including the getRequestContext() === undefined teardown assertion.
  • Conventions: the discovery query uses the Kysely builder (no interpolation); changeset is present and well-written (emdash patch); no admin UI strings/RTL concerns.

Conclusion

No correctness bugs found. The code is safe to merge as-is; the headline decision for the maintainer is whether the unmeasured wall-clock win justifies the deliberate query-count increase, and the single suggestion below is the one per-request cost most worth trimming if the measurement is marginal.

const db = await getDb();
// The layout calls getMenu(name) with hardcoded names; we can't know them, so
// discover every menu name and warm them all (small, bounded chrome table).
const rows = await db.selectFrom("_emdash_menus").select("name").distinct().execute();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This SELECT DISTINCT name FROM _emdash_menus runs on every anonymous HTML request and is awaited before the per-menu getMenu(name) calls, so on a non-coalescing D1 session it adds a serial round trip that partially offsets the overlap win this PR is after (on a coalescing backend it batches for free, which is the better case). Menu names change only when an admin creates/renames/deletes a menu, so this is a good candidate for an isolate-scoped cache mirroring getSiteSettings()'s SITE_SETTINGS_CACHE_KEY / invalidateSiteSettingsCache() (settings/index.ts), invalidated from the menu write paths. After the first request per isolate the discovery would be a free cache hit instead of a per-request query. Not a correctness issue — the prefetch is correct as written — just the one per-request cost worth eliminating if the measurement shows the discovery RTT matters.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jun 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core bot:review Trigger an emdashbot code review on this PR cla: signed review/awaiting-author Reviewed; waiting on the author to respond size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants