perf(core): eagerly prefetch site-global layout data on public pages#1509
Conversation
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 detectedLatest commit: 4af6d6b The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
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 |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 4af6d6b | Jun 16 2026, 04:43 PM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | 4af6d6b | Jun 16 2026, 04:43 PM |
Deploying with
|
| 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
There was a problem hiding this comment.
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 satisfiesgetSiteSetting(key)viapeekRequestCache), 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()andgetWidgetArea(name)build identicalWidgetAreaobjects (same columns, samesort_orderordering, samerowToWidget), andsetRequestCacheEntry'sif (cache.has(key)) returnguard 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()schedulesprefetchLayoutDataviaPromise.resolve().then(fn)whilectxis active insiderunWithContext, sofn(and itsawaitcontinuations) run withctx—getDb()/getRequestContext()resolve to the request-scoped db. The surplus queries that outlive the response stay withinctx's scope and reference it, so the WeakMap-keyed request cache isn't collected early. commit()safety: for the anonymous pathcommit()returns immediately (no bookmark for anon), and the D1/DO adapters'destroy()are no-ops, so the scoped Kysely stays usable through thewaitUntilwindow — 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. TheacceptsHtmlleading-media-range parse correctly excludes feeds (which lead withapplication/rss+xml) and is safe for empty/*/*headers ("".split(",",1)[0]is"", and the!is sound sincesplitalways yields ≥1 element). - Error handling:
Promise.allSettled+ outer try/catch mean a prefetch failure never surfaces to the request;requestCacheddeletes 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.tscase 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: theirRequests carry notext/htmlAccept(verifiednew Request(url).headers.get("accept")isnull), soacceptsHtmlis false and the prefetch path is skipped, including thegetRequestContext() === undefinedteardown assertion. - Conventions: the discovery query uses the Kysely builder (no interpolation); changeset is present and well-written (
emdashpatch); 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(); |
There was a problem hiding this comment.
[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.
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
requestCachedentry.Fully transparent — no template changes. Stacked conceptually on #1498 (the per-request cache reuse it relies on is now in
main).Implementation notes:
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 bywaitUntilrather than orphaning request I/O on workerd.text/htmltype, so feeds / sitemaps / JSON endpoints don't pay for chrome they never render.Honest caveats (please weigh before merging)
db.count; on remote backends it lowers effective round trips by overlapping them. It actually raisesdb.countslightly: it over-fetches (loads all chrome, since middleware can't know which a template renders) and adds oneSELECT DISTINCTmenu-name discovery query.commit()leaving the request-scoped db usable during thewaitUntilwindow — true for the D1 and DO adapters (bookmark-persist, no close), but core can't prove it.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
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change) — added a prefetch cache-warm test; core suite greenpnpm formathas been runemdashpatch)AI-generated code disclosure
Screenshots / test output
Added
menu-request-cache.test.tscase provingprefetchLayoutData()warms menus so the layout'sgetMenucalls 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.