diff --git a/.changeset/fast-wolves-render.md b/.changeset/fast-wolves-render.md
new file mode 100644
index 000000000000..28ba531e297e
--- /dev/null
+++ b/.changeset/fast-wolves-render.md
@@ -0,0 +1,7 @@
+---
+'astro': patch
+---
+
+Improves `.astro` component SSR rendering performance by up to 2x.
+
+This includes several optimizations to the way that Astro generates and renders components on the server. These are mostly micro-optimizations, but they add up to a significant improvement in performance. Most pages will benefit, but pages with many components will see the biggest improvement, as will pages with lots of strings (e.g. text-heavy pages with lots of HTML elements).
diff --git a/benchmark/bench/codspeed-setup.js b/benchmark/bench/codspeed-setup.js
index 23c6bbdaa7de..043ab45c3051 100644
--- a/benchmark/bench/codspeed-setup.js
+++ b/benchmark/bench/codspeed-setup.js
@@ -4,21 +4,23 @@ import { astroBin, makeProject } from './_util.js';
/**
* Setup script for codspeed benchmarks.
- * This builds the render-bench project which is required for the rendering benchmarks.
+ * This builds the benchmark projects which are required for the rendering benchmarks.
* This is separated out so it can run in a separate CI job since it takes a long time.
*/
async function setup() {
- console.log('Setting up render-bench project...');
- const render = await makeProject('render-bench');
- const root = fileURLToPath(render);
+ for (const name of ['render-bench', 'rendering-perf']) {
+ console.log(`Setting up ${name} project...`);
+ const projectDir = await makeProject(name);
+ const root = fileURLToPath(projectDir);
- console.log(`Building project at ${root}...`);
- await exec(astroBin, ['build'], {
- nodeOptions: {
- cwd: root,
- stdio: 'inherit',
- },
- });
+ console.log(`Building project at ${root}...`);
+ await exec(astroBin, ['build'], {
+ nodeOptions: {
+ cwd: root,
+ stdio: 'inherit',
+ },
+ });
+ }
}
setup().catch((error) => {
diff --git a/benchmark/bench/rendering-perf.bench.js b/benchmark/bench/rendering-perf.bench.js
new file mode 100644
index 000000000000..db71f7691570
--- /dev/null
+++ b/benchmark/bench/rendering-perf.bench.js
@@ -0,0 +1,90 @@
+import { existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { beforeAll, bench, describe } from 'vitest';
+
+/**
+ * Rendering performance benchmarks targeting specific hot paths from RENDERING_PERF_PLAN.md.
+ *
+ * Each page isolates a different performance pattern:
+ * - many-components: #1 markHTMLString, #2 isHTMLString, #6 validateComponentProps
+ * - many-expressions: #2 isHTMLString, #5 renderChild dispatch, #10 escapeHTML
+ * - many-head-elements: #3 head dedup O(N²)
+ * - many-slots: #9 eager slot prerendering
+ * - large-array: #8 BufferedRenderer per array child
+ * - static-heavy: #1 markHTMLString baseline, #11/#12 future comparison
+ *
+ * Requires: pnpm run build:bench
+ */
+
+const projectRoot = new URL('../projects/rendering-perf/', import.meta.url);
+
+let streamingApp;
+let nonStreamingApp;
+
+beforeAll(async () => {
+ const entry = new URL('./dist/server/entry.mjs', projectRoot);
+
+ if (!existsSync(fileURLToPath(entry))) {
+ throw new Error(
+ 'rendering-perf project not built. Please run `pnpm run build:bench` before running the benchmarks.',
+ );
+ }
+
+ const { createApp } = await import(entry);
+ streamingApp = createApp(true);
+ nonStreamingApp = createApp(false);
+}, 900000);
+
+// Non-streaming (prerender path) — this is the primary target for most optimizations
+// since it's the path where all the overhead is synchronous and measurable.
+describe('Rendering perf (non-streaming)', () => {
+ bench('many-components (markHTMLString, isHTMLString, validateProps)', async () => {
+ const request = new Request(new URL('http://example.com/many-components'));
+ await nonStreamingApp.render(request);
+ });
+
+ bench('many-expressions (renderChild dispatch, escapeHTML)', async () => {
+ const request = new Request(new URL('http://example.com/many-expressions'));
+ await nonStreamingApp.render(request);
+ });
+
+ bench('many-head-elements (head dedup)', async () => {
+ const request = new Request(new URL('http://example.com/many-head-elements'));
+ await nonStreamingApp.render(request);
+ });
+
+ bench('many-slots (eager slot prerendering)', async () => {
+ const request = new Request(new URL('http://example.com/many-slots'));
+ await nonStreamingApp.render(request);
+ });
+
+ bench('large-array (BufferedRenderer per child)', async () => {
+ const request = new Request(new URL('http://example.com/large-array'));
+ await nonStreamingApp.render(request);
+ });
+
+ bench('static-heavy (markHTMLString baseline)', async () => {
+ const request = new Request(new URL('http://example.com/static-heavy'));
+ await nonStreamingApp.render(request);
+ });
+});
+
+// Streaming path — included for comparison. Optimizations to the sync path
+// (#1, #2, #5, #6) should show up here too, but BufferedRenderer (#8) and
+// slot prerendering (#9) may behave differently.
+describe('Rendering perf (streaming)', () => {
+ bench('many-components [streaming]', async () => {
+ const request = new Request(new URL('http://example.com/many-components'));
+ await streamingApp.render(request);
+ });
+
+ bench('many-expressions [streaming]', async () => {
+ const request = new Request(new URL('http://example.com/many-expressions'));
+ await streamingApp.render(request);
+ });
+
+ bench('large-array [streaming]', async () => {
+ const request = new Request(new URL('http://example.com/large-array'));
+ await streamingApp.render(request);
+ });
+});
diff --git a/benchmark/make-project/rendering-perf.js b/benchmark/make-project/rendering-perf.js
new file mode 100644
index 000000000000..453972ca5dc2
--- /dev/null
+++ b/benchmark/make-project/rendering-perf.js
@@ -0,0 +1,285 @@
+import fs from 'node:fs/promises';
+import { loremIpsumHtml } from './_util.js';
+
+/**
+ * Generates a benchmark project targeting specific rendering hot paths
+ * identified in RENDERING_PERF_PLAN.md. Each page isolates a different
+ * performance-sensitive pattern so we can measure the impact of optimizations.
+ */
+
+// ---------------------------------------------------------------------------
+// Components
+// ---------------------------------------------------------------------------
+
+const components = {
+ // A leaf component with ~4 static HTML parts and a few expressions.
+ // Used by many-components to stress markHTMLString + isHTMLString (#1, #2).
+ 'components/Card.astro': `\
+---
+const { title, href, index } = Astro.props;
+---
+
+
+
+
Card number {index}, rendering static content around expressions.
+
+
+
+`,
+
+ // A wrapper component that renders children via a default slot.
+ // Used by many-slots to stress eager slot prerendering (#9).
+ 'components/Section.astro': `\
+---
+const { heading } = Astro.props;
+---
+
+`,
+
+ // A component with 3 named slots — only default is typically used.
+ // Stresses eager slot prerendering (#9).
+ 'components/Layout.astro': `\
+---
+const { title } = Astro.props;
+---
+
+
+ {title}
+
+
+
+
+
+
+
+
+`,
+
+ // A component that contributes a
+`,
+
+ // A component that contributes a unique
+`,
+};
+
+// ---------------------------------------------------------------------------
+// Pages
+// ---------------------------------------------------------------------------
+
+const pages = {
+ // #1, #2, #6: 200 Astro component instances, each with ~4 HTML parts.
+ // Stresses markHTMLString allocs, isHTMLString checks, validateComponentProps.
+ 'pages/many-components.astro': `\
+---
+import Card from '../components/Card.astro';
+const items = Array.from({ length: 200 }, (_, i) => ({
+ title: \`Card \${i}\`,
+ href: \`/card/\${i}\`,
+ index: i,
+}));
+---
+
+ Many Components
+
+ 200 Component Instances
+ {items.map((item) => (
+
+ ))}
+
+
+`,
+
+ // #2, #5, #10: Thousands of text expressions ({value}).
+ // Stresses renderChild dispatch ordering, isHTMLString, escapeHTML.
+ 'pages/many-expressions.astro': `\
+---
+const items = Array.from({ length: 2000 }, (_, i) => ({
+ name: \`Item \${i}\`,
+ value: i * 17,
+ label: i % 2 === 0 ? "even" : "odd",
+}));
+const title = "Expression Heavy Page";
+const subtitle = "Testing renderChild dispatch";
+---
+
+ {title}
+
+ {title}
+ {subtitle}
+
+
+ | Name | Value | Label |
+
+
+ {items.map((item) => (
+
+ | {item.name} |
+ {item.value} |
+ {item.label} |
+
+ ))}
+
+
+
+
+`,
+
+ // #3: 60 components each contributing styles to .
+ // Stresses head deduplication O(N^2) with JSON.stringify.
+ 'pages/many-head-elements.astro': `\
+---
+import StyledWidget from '../components/StyledWidget.astro';
+import UniqueStyled from '../components/UniqueStyled.astro';
+// 30 instances of same component (dedup should collapse these)
+const duplicated = Array.from({ length: 30 }, (_, i) => i);
+// 30 instances with unique styles (dedup must compare all)
+const unique = Array.from({ length: 30 }, (_, i) => i);
+---
+
+ Many Head Elements
+
+ Head Deduplication Stress Test
+ {duplicated.map((i) => (
+
+ ))}
+ {unique.map((i) => (
+
+ ))}
+
+
+`,
+
+ // #9: Components with multiple named slots, only default used.
+ // Stresses eager slot prerendering of unused slots.
+ 'pages/many-slots.astro': `\
+---
+import Layout from '../components/Layout.astro';
+import Section from '../components/Section.astro';
+const sections = Array.from({ length: 100 }, (_, i) => ({
+ heading: \`Section \${i}\`,
+ content: \`Content for section \${i} with some text to render.\`,
+}));
+---
+
+ 100 Sections with Slots
+ {sections.map((s) => (
+
+ ))}
+
+`,
+
+ // #8: Large array .map() with component children.
+ // Stresses BufferedRenderer-per-array-child allocation.
+ 'pages/large-array.astro': `\
+---
+import Card from '../components/Card.astro';
+const items = Array.from({ length: 5000 }, (_, i) => ({
+ title: \`Item \${i}\`,
+ href: \`/item/\${i}\`,
+ index: i,
+}));
+---
+
+ Large Array
+
+ 5000 Array Items with Components
+
+ {items.map((item) => (
+
+ ))}
+
+
+
+`,
+
+ // #1, #11, #12: Mostly static HTML with very few expressions.
+ // Baseline for measuring overhead of the rendering machinery on static content.
+ 'pages/static-heavy.astro': `\
+---
+const title = "Static Heavy Page";
+---
+
+ {title}
+
+ {title}
+ ${Array.from({ length: 200 })
+ .map(
+ (_, i) => `
+ Section ${i}
+ ${loremIpsumHtml}
+ ${loremIpsumHtml}
+ `,
+ )
+ .join('\n ')}
+
+
+`,
+};
+
+// ---------------------------------------------------------------------------
+// Exports
+// ---------------------------------------------------------------------------
+
+export const renderPages = Object.keys(pages)
+ .filter((f) => f.startsWith('pages/'))
+ .map((f) => f.replace('pages/', ''));
+
+/**
+ * @param {URL} projectDir
+ */
+export async function run(projectDir) {
+ await fs.rm(projectDir, { recursive: true, force: true });
+ await fs.mkdir(new URL('./src/pages', projectDir), { recursive: true });
+ await fs.mkdir(new URL('./src/components', projectDir), { recursive: true });
+
+ const allFiles = { ...components, ...pages };
+
+ await Promise.all(
+ Object.entries(allFiles).map(([name, content]) => {
+ return fs.writeFile(new URL(`./src/${name}`, projectDir), content, 'utf-8');
+ }),
+ );
+
+ await fs.writeFile(
+ new URL('./astro.config.js', projectDir),
+ `\
+import { defineConfig } from 'astro/config';
+import adapter from '@benchmark/adapter';
+
+export default defineConfig({
+ output: 'server',
+ adapter: adapter(),
+});`,
+ 'utf-8',
+ );
+}
diff --git a/packages/astro/src/runtime/server/escape.ts b/packages/astro/src/runtime/server/escape.ts
index 1dbfd3725a88..1bd90785dbf8 100644
--- a/packages/astro/src/runtime/server/escape.ts
+++ b/packages/astro/src/runtime/server/escape.ts
@@ -48,7 +48,7 @@ export const markHTMLString = (value: any) => {
};
export function isHTMLString(value: any): value is HTMLString {
- return Object.prototype.toString.call(value) === '[object HTMLString]';
+ return value instanceof HTMLString;
}
function markHTMLBytes(bytes: Uint8Array) {
diff --git a/packages/astro/src/runtime/server/render/any.ts b/packages/astro/src/runtime/server/render/any.ts
index b56ca7cdac87..4f17e28cd01b 100644
--- a/packages/astro/src/runtime/server/render/any.ts
+++ b/packages/astro/src/runtime/server/render/any.ts
@@ -6,6 +6,13 @@ import { SlotString } from './slot.js';
import { createBufferedRenderer } from './util.js';
export function renderChild(destination: RenderDestination, child: any): void | Promise {
+ // Strings are the most common child type (text expressions like {title}, {name})
+ // so check them first for the fastest dispatch in the common case.
+ if (typeof child === 'string') {
+ destination.write(markHTMLString(escapeHTML(child)));
+ return;
+ }
+
if (isPromise(child)) {
return child.then((x) => renderChild(destination, x));
}
@@ -20,6 +27,11 @@ export function renderChild(destination: RenderDestination, child: any): void |
return;
}
+ if (!child && child !== 0) {
+ // do nothing, safe to ignore falsey values.
+ return;
+ }
+
if (Array.isArray(child)) {
return renderArray(destination, child);
}
@@ -31,16 +43,6 @@ export function renderChild(destination: RenderDestination, child: any): void |
return renderChild(destination, child());
}
- if (!child && child !== 0) {
- // do nothing, safe to ignore falsey values.
- return;
- }
-
- if (typeof child === 'string') {
- destination.write(markHTMLString(escapeHTML(child)));
- return;
- }
-
if (isRenderInstance(child)) {
return child.render(destination);
}
@@ -70,32 +72,43 @@ export function renderChild(destination: RenderDestination, child: any): void |
}
function renderArray(destination: RenderDestination, children: any[]): void | Promise {
- // Render all children eagerly and in parallel
- const flushers = children.map((c) => {
- return createBufferedRenderer(destination, (bufferDestination) => {
- return renderChild(bufferDestination, c);
- });
- });
-
- const iterator = flushers[Symbol.iterator]();
-
- const iterate = (): void | Promise => {
- for (;;) {
- const { value: flusher, done } = iterator.next();
-
- if (done) {
- break;
+ // Fast path: render children one at a time directly to the destination.
+ // If all children are sync, no buffering is needed at all.
+ // If a child returns a Promise, fall back to buffered rendering for
+ // the remaining children to preserve output ordering.
+ for (let i = 0; i < children.length; i++) {
+ const result = renderChild(destination, children[i]);
+
+ if (isPromise(result)) {
+ // This child is async. Buffer remaining children in parallel
+ // to preserve ordering, then flush them sequentially.
+ if (i + 1 >= children.length) {
+ // No remaining children, just wait for this one
+ return result;
}
- const result = flusher.flush();
-
- if (isPromise(result)) {
- return result.then(iterate);
+ const remaining = children.length - i - 1;
+ const flushers = new Array(remaining);
+ for (let j = 0; j < remaining; j++) {
+ flushers[j] = createBufferedRenderer(destination, (bufferDestination) => {
+ return renderChild(bufferDestination, children[i + 1 + j]);
+ });
}
- }
- };
- return iterate();
+ return result.then(() => {
+ let k = 0;
+ const iterate = (): void | Promise => {
+ while (k < flushers.length) {
+ const flushResult = flushers[k++].flush();
+ if (isPromise(flushResult)) {
+ return flushResult.then(iterate);
+ }
+ }
+ };
+ return iterate();
+ });
+ }
+ }
}
function renderIterable(
diff --git a/packages/astro/src/runtime/server/render/astro/render-template.ts b/packages/astro/src/runtime/server/render/astro/render-template.ts
index 330368a6ab1b..48d3be8fa9e3 100644
--- a/packages/astro/src/runtime/server/render/astro/render-template.ts
+++ b/packages/astro/src/runtime/server/render/astro/render-template.ts
@@ -33,44 +33,71 @@ export class RenderTemplateResult {
}
render(destination: RenderDestination): void | Promise {
- // Render all expressions eagerly and in parallel
- const flushers = this.expressions.map((exp) => {
- return createBufferedRenderer(destination, (bufferDestination) => {
- // Skip render if falsy, except the number 0
- if (exp || exp === 0) {
- return renderChild(bufferDestination, exp);
- }
- });
- });
+ // Fast path: render HTML parts and expressions directly to the
+ // destination without buffering. If all expressions are sync,
+ // this avoids all BufferedRenderer allocations. When an async
+ // expression is encountered, fall back to buffered rendering
+ // for the remaining expressions to preserve output ordering.
+ //
+ // Template structure: html[0] exp[0] html[1] exp[1] ... html[N]
+ // (htmlParts.length === expressions.length + 1)
+ const { htmlParts, expressions } = this;
- let i = 0;
+ for (let i = 0; i < htmlParts.length; i++) {
+ const html = htmlParts[i];
+ if (html) {
+ destination.write(markHTMLString(html));
+ }
- const iterate = (): void | Promise => {
- while (i < this.htmlParts.length) {
- const html = this.htmlParts[i];
- const flusher = flushers[i];
+ // expressions[i] doesn't exist for the last htmlPart
+ if (i >= expressions.length) break;
- // increment here due to potential return in
- // Promise scenario
- i++;
+ const exp = expressions[i];
+ // Skip render if falsy, except the number 0
+ if (!(exp || exp === 0)) continue;
- if (html) {
- // only write non-empty strings
+ const result = renderChild(destination, exp);
- destination.write(markHTMLString(html));
+ if (isPromise(result)) {
+ // This expression is async. Buffer remaining expressions
+ // in parallel to preserve ordering, then flush sequentially.
+ const startIdx = i + 1;
+ const remaining = expressions.length - startIdx;
+ const flushers = new Array(remaining);
+ for (let j = 0; j < remaining; j++) {
+ const rExp = expressions[startIdx + j];
+ flushers[j] = createBufferedRenderer(destination, (bufferDestination) => {
+ if (rExp || rExp === 0) {
+ return renderChild(bufferDestination, rExp);
+ }
+ });
}
- if (flusher) {
- const result = flusher.flush();
+ return result.then(() => {
+ let k = 0;
+ const iterate = (): void | Promise => {
+ while (k < flushers.length) {
+ // Write the HTML part that precedes this expression
+ const rHtml = htmlParts[startIdx + k];
+ if (rHtml) {
+ destination.write(markHTMLString(rHtml));
+ }
- if (isPromise(result)) {
- return result.then(iterate);
- }
- }
+ const flushResult = flushers[k++].flush();
+ if (isPromise(flushResult)) {
+ return flushResult.then(iterate);
+ }
+ }
+ // Write the final trailing HTML part
+ const lastHtml = htmlParts[htmlParts.length - 1];
+ if (lastHtml) {
+ destination.write(markHTMLString(lastHtml));
+ }
+ };
+ return iterate();
+ });
}
- };
-
- return iterate();
+ }
}
}
diff --git a/packages/astro/src/runtime/server/render/head.ts b/packages/astro/src/runtime/server/render/head.ts
index 48ce553dea98..333cce4dfe9e 100644
--- a/packages/astro/src/runtime/server/render/head.ts
+++ b/packages/astro/src/runtime/server/render/head.ts
@@ -5,14 +5,30 @@ import type { MaybeRenderHeadInstruction, RenderHeadInstruction } from './instru
import { createRenderInstruction } from './instruction.js';
import { renderElement } from './util.js';
-// Filter out duplicate elements in our set
-const uniqueElements = (item: any, index: number, all: any[]) => {
- const props = JSON.stringify(item.props);
- const children = item.children;
- return (
- index === all.findIndex((i) => JSON.stringify(i.props) === props && i.children === children)
- );
-};
+// Deterministic stringification of props that is key-order independent,
+// so elements with the same props in different insertion order are still deduped.
+function stablePropsKey(props: Record): string {
+ const keys = Object.keys(props).sort();
+ let result = '{';
+ for (let i = 0; i < keys.length; i++) {
+ if (i > 0) result += ',';
+ result += JSON.stringify(keys[i]) + ':' + JSON.stringify(props[keys[i]]);
+ }
+ result += '}';
+ return result;
+}
+
+// Filter out duplicate elements using a Set for O(N) instead of O(N²)
+function deduplicateElements(elements: any[]): any[] {
+ if (elements.length <= 1) return elements;
+ const seen = new Set();
+ return elements.filter((item) => {
+ const key = stablePropsKey(item.props) + item.children;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+}
export function renderAllHeadContent(result: SSRResult) {
result._metadata.hasRenderedHead = true;
@@ -30,27 +46,21 @@ export function renderAllHeadContent(result: SSRResult) {
false,
);
}
- const styles = Array.from(result.styles)
- .filter(uniqueElements)
- .map((style) =>
- style.props.rel === 'stylesheet'
- ? renderElement('link', style)
- : renderElement('style', style),
- );
+ const styles = deduplicateElements(Array.from(result.styles)).map((style) =>
+ style.props.rel === 'stylesheet' ? renderElement('link', style) : renderElement('style', style),
+ );
// Clear result.styles so that any new styles added will be inlined.
result.styles.clear();
- const scripts = Array.from(result.scripts)
- .filter(uniqueElements)
- .map((script) => {
- if (result.userAssetsBase) {
- script.props.src =
- (result.base === '/' ? '' : result.base) + result.userAssetsBase + script.props.src;
- }
- return renderElement('script', script, false);
- });
- const links = Array.from(result.links)
- .filter(uniqueElements)
- .map((link) => renderElement('link', link, false));
+ const scripts = deduplicateElements(Array.from(result.scripts)).map((script) => {
+ if (result.userAssetsBase) {
+ script.props.src =
+ (result.base === '/' ? '' : result.base) + result.userAssetsBase + script.props.src;
+ }
+ return renderElement('script', script, false);
+ });
+ const links = deduplicateElements(Array.from(result.links)).map((link) =>
+ renderElement('link', link, false),
+ );
// Order styles -> links -> scripts similar to src/content/runtime.ts
// The order is usually fine as the ordering between these groups are mutually exclusive,