Skip to content

Render wrapped pages with a single renderPage - #99

Open
dajinchu wants to merge 10 commits into
masterfrom
dc/rails-server-slow-latency
Open

dajinchu wants to merge 10 commits into
masterfrom
dc/rails-server-slow-latency

Conversation

@dajinchu

@dajinchu dajinchu commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Wrapped pages used to run renderPage twice: once in preHandler just to read the route's config, then again with Rails' HTML. Now Rails is requested inside the render, and a wrapped page renders once, which cuts its server CPU by about 30%. It renders a second time only in one case, below.

This branch also carries two earlier commits:

  • 0b548f9 (Install a single vike and fix hardNavigate return type) is the same commit as in Fix versioning #98. Please merge Fix versioning #98 first; I'll rebase.
  • 552183c adds the server benchmarks in tests/bench used for the numbers below.

How it works

  • bifrost-fastify passes a per-request loader in pageContextInit, and the wrapped renderer's new +onCreatePageContext calls it. The loader requests Rails through reply-from and holds the response instead of sending it.
  • If Rails' page can be wrapped, the render continues with it. Otherwise (a redirect, non-HTML, no layout, or a page without <head>/<body>), Rails' response is sent as-is after renderPage returns, as before.
  • Rails is requested at most once per request, and buildPageContextInit still re-runs after beforeWrappedRender (run buildPageContextInit again when rendering wrapped pages #71).
  • If Rails errors or drops the connection, Bifrost still responds with an error. A client disconnect also releases the wait, since reply-from never calls back if an HTTP/2 client disconnects first.

App +onCreatePageContext hooks

Vike runs +onCreatePageContext hooks concurrently, so an app's hook can't see the wrapped page unless it awaits it. Apps keep working without changes:

  • Only Bifrost's hook (or the app's hook awaits loadWrappedPage(pageContext)): one render.
  • Other hooks, none awaiting loadWrappedPage: Bifrost renders the page a second time, so those hooks see it. Its hook throws render(url, abortReason) and onBeforeRoute adds the page before any hook runs. Keeping abortReason keeps routes reached by throw render(url, { proxy: "wrapped" }) (like Alignable's renderWrapped) from re-running the page that threw it. Rails is still requested once.

loadWrappedPage is exported from @alignable/bifrost, and the README recommends it. In the bench, the second render adds about 0.5 ms of CPU per wrapped page (roughly 20%).

For Alignable: wrapped pages work after the bump as-is. To get the single render, add this to frontend/renderer/+onCreatePageContext.shared.ts:

if (pageContext.config.proxyMode === "wrapped" && !(await loadWrappedPage(pageContext))) return;

Performance

npm run bench -- -c 1,10, base (552183c) and this branch measured back to back. The bench pages have only Bifrost's hook:

Scenario CPU ms/req (c=10) req/s (c=10) p50 at c=1
wrapped 3.62 → 2.49 (−31%) 296 → 436 (+47%) 4.87 → 4.00 ms
wrapped client nav 1.07 → 1.07 983 → 1010 1.30 → 1.20 ms
passthru 1.09 → 1.11 964 → 977 1.36 → 1.24 ms
native 5.71 → 5.39 188 → 199 6.88 → 6.54 ms

Passthru and client navigation still run one renderPage to read the route's config; that's a follow-up.

README

Rewritten to be concise: what Bifrost is, how requests flow, and integration steps (Vike config, Fastify server, the Rails side, wrapped and passthru routes, navigation). It includes the loadWrappedPage performance note.

Testing

  • http.spec.ts covers each path. It uses dedicated test routes with an app-level +onCreatePageContext, and checks the number of renders (via pageContextsAborted) and that the backend was requested exactly once:
    • only Bifrost's hook: one render;
    • an app hook that awaits loadWrappedPage: one render;
    • an app hook that doesn't: two renders, and the hook sees the page;
    • a page that threw render(url, { proxy: "wrapped" }): exactly two aborts.
  • The fake backend dropping the connection gets a 5xx instead of hanging.
  • Each of the new tests fails when the behavior it guards is broken (memoization, error settlement, abortReason, the loadWrappedPage mark).
  • e2e: 315 passed, twice. The full parallel suite has intermittent timing failures locally, on the base commit too. They pass when repeated in isolation.

Release

Release both packages together. bifrost-fastify pins @alignable/bifrost exactly and relies on the new hook.

🤖 Generated with Claude Code

@reinabo reinabo 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.

looks good overall

Comment thread bifrost-fastify/index.ts
Comment thread bifrost-fastify/index.ts Outdated
Comment thread bifrost/renderer/wrapped/onCreatePageContext.ts
Comment thread bifrost/renderer/wrapped/onCreatePageContext.ts
dajinchu and others added 10 commits October 7, 2026 12:55
Adds tests/bench: an HTTP benchmark of the production Bifrost server
against a fixture backend, with wrapped, passthru and native scenarios
whose pages are shaped like Alignable's sign-in, business profile and
events listing. It reports CPU per request (sampled in the server over
IPC), throughput and latency across connection levels, can simulate
Rails latency, captures CPU profiles, and checks each response is
correct before measuring. Also adds an extractDomElements microbench.

The tests/vite server now reads its upstream and public URLs from
UPSTREAM_URL and PUBLIC_URL, defaulting to the previous values.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Wrapped pages used to run renderPage twice: once in preHandler only to
read the route's config, and again with the backend's HTML. Now the
backend request happens inside the one render: the wrapped renderer's
+onCreatePageContext calls back into bifrost-fastify, which requests the
backend through reply-from and holds the response instead of sending it.
If the response is wrappable the render continues with it; otherwise
(redirect, non-HTML, no layout) the held response is sent as-is after
renderPage returns. buildPageContextInit still re-runs after
beforeWrappedRender, as in #71.

Vike runs an app's +onCreatePageContext before Bifrost's, so the backend
request is memoized per request and exported as loadWrappedPage(): apps
whose hook needs the wrapped page, or request state the backend response
changes, should await it first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- An opt-in app-level +onCreatePageContext in tests/vite awaits
  loadWrappedPage, as Alignable's does. Vike runs it before Bifrost's
  hook. The test checks it sees the wrapped page and that the fake
  backend was requested once. The backend now counts requests per page,
  served at /__hits.
- A fake-backend page that drops the connection checks that Bifrost
  responds with an error instead of hanging.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Vike runs +onCreatePageContext hooks concurrently, not app hooks
first as the earlier commits said. An app hook therefore can't see the
backend's page unless it awaits loadWrappedPage. Instead of requiring
that, Bifrost now renders the page a second time if other
+onCreatePageContext hooks exist and none awaited loadWrappedPage. The
backend is still requested once.

- The memoized loader returns the page's pageContext additions: the
  wrapped page plus buildPageContextInit's result after
  beforeWrappedRender.
- For the second render, Bifrost's hook throws render(url,
  abortReason), and onBeforeRoute adds the page before any hook runs.
  Keeping abortReason keeps routes reached by `throw render(url, {
  proxy: "wrapped" })` from re-running the page that threw it.
- loadWrappedPage marks the request so Bifrost skips the second render.
  It does nothing outside wrapped routes.

The app-hook tests move to their own routes. Together they cover the
paths: Bifrost's hook alone, an app hook that awaits loadWrappedPage,
one that doesn't, and a page that threw render(url, { proxy:
"wrapped" }).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Says what Bifrost is and how requests flow, then gives integration
steps: Vike config, Fastify server, the Rails side, and wrapped and
passthru routes. Adds a section on awaiting loadWrappedPage in an
app's +onCreatePageContext for better wrapped-page performance.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
If getLayout, reading Rails' body, extractDomElements or
buildPageContextInit threw while wrapping, Vike rendered its error
page, but Bifrost discarded it and sent Rails' page with a 200. That
page is missing its layout, because Rails rendered it for wrapping.
Bifrost now sends the error page (500, and onError runs), discards
Rails' response and strips the layout headers.

Also stops adding the proxy headers to the incoming request's headers.
That object is pageContext's headersOriginal, so pageContext.headers
went stale. rewriteRequestHeaders now adds them to the backend
request only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The first time Bifrost renders a wrapped page twice, it logs which
+onCreatePageContext files should await loadWrappedPage.

The README now notes that Vike's onCreatePageContext timeout (warning
after 4 s, error after 30 s by default) applies to the wait for Rails,
and shows how to raise it on wrapped routes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ses safely

A `throw redirect()` from a hook that runs after Bifrost has the
backend's response, e.g. a +guard on a wrapped route, was sent with the
backend's status (usually 200) if the page was wrappable, or dropped for
the backend's response if it wasn't. Bifrost now sends Vike's redirect,
with bifrostProxyMode "wrapped" like the error page path.

Held responses that aren't sent were destroy()ed. On an unread undici
body that emits an error nobody listens for, an uncaught exception that
only Vike's global handler keeps from crashing the server, and closes
the keep-alive connection to the backend. They're now dumped: undici
reads up to 128 KiB so the connection is reused, and closes it for
larger pages.

A client disconnecting while Bifrost waits for the backend was logged as
a server error with a 500. It's now answered with 499.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@dajinchu
dajinchu force-pushed the dc/rails-server-slow-latency branch from 54d1769 to 4a5bbaf Compare October 7, 2026 17:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants