Skip to content

perf: SSR rendering optimizations#15605

Merged
ascorbic merged 4 commits into
mainfrom
bench/rendering-perf
Feb 23, 2026
Merged

perf: SSR rendering optimizations#15605
ascorbic merged 4 commits into
mainfrom
bench/rendering-perf

Conversation

@ascorbic
Copy link
Copy Markdown
Contributor

@ascorbic ascorbic commented Feb 21, 2026

Changes

  • Replaces Object.prototype.toString.call() with instanceof for isHTMLString detection
  • Reorders renderChild type dispatch to check strings first (most common child type)
  • Eliminates O(N²) head element deduplication by using a Set
  • Renders array children and template expressions directly to the destination without buffering when synchronous, falling back to BufferedRenderer only when a Promise is encountered
  • Adds targeted rendering benchmarks (rendering-perf.bench.js) covering many-components, many-expressions, many-head-elements, many-slots, large-array, and static-heavy page shapes

Codspeed results: all benchmarks improve or stay flat, no regressions on .md or .mdx routes. The .astro page benchmark (10K-item list) doubles in throughput.

Research done with help from Claude. Benchmarks by Claude.

Testing

New benchmarks in benchmark/bench/rendering-perf.bench.js and existing benchmark/bench/render.bench.js.

Results from local tests, compared to main:

rendering-perf.bench.js (non-streaming)

Bench Original (Hz) Final (Hz) Total change
many-components 2,517 3,918 +56%
many-expressions 669 1,291 +93%
many-head-elements 7,662 11,997 +57%
many-slots 4,200 5,700 +36%
large-array 81 170 +110%
static-heavy 7,705 9,047 +17%

render.bench.js (realistic pages)

Bench Original (Hz) Final (Hz) Total change
.astro streaming 64.8 128.1 +98%
.astro non-streaming 62.4 129.0 +107%

Docs

Internal optimizations only — no public API or behavior changes. No docs needed.

Add benchmarks isolating specific rendering hot paths:
- many-components: markHTMLString/isHTMLString/validateComponentProps
- many-expressions: renderChild dispatch ordering, escapeHTML
- many-head-elements: head deduplication
- many-slots: eager slot prerendering
- large-array: BufferedRenderer per array child
- static-heavy: baseline for static HTML overhead
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Feb 21, 2026

🦋 Changeset detected

Latest commit: 4050a6d

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

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

@github-actions github-actions Bot added the pkg: astro Related to the core `astro` package (scope) label Feb 21, 2026
@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Feb 21, 2026

Merging this PR will improve performance by 72.83%

⚡ 2 improved benchmarks
✅ 7 untouched benchmarks
🆕 9 new benchmarks

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation Rendering: streaming [true], .astro file 348.3 ms 214.7 ms +62.21%
Simulation Rendering: streaming [false], .astro file 337.7 ms 195.4 ms +72.83%
🆕 Simulation many-components (markHTMLString, isHTMLString, validateProps) N/A 8.7 ms N/A
🆕 Simulation large-array (BufferedRenderer per child) N/A 159.1 ms N/A
🆕 Simulation many-expressions (renderChild dispatch, escapeHTML) N/A 23.7 ms N/A
🆕 Simulation static-heavy (markHTMLString baseline) N/A 6 ms N/A
🆕 Simulation many-expressions [streaming] N/A 23.5 ms N/A
🆕 Simulation many-slots (eager slot prerendering) N/A 5.3 ms N/A
🆕 Simulation many-components [streaming] N/A 7.9 ms N/A
🆕 Simulation large-array [streaming] N/A 148.9 ms N/A
🆕 Simulation many-head-elements (head dedup) N/A 5 ms N/A

Comparing bench/rendering-perf (4050a6d) with main (999a7dd)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (1cc62dc) during the generation of this report, so 999a7dd was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@ascorbic ascorbic force-pushed the bench/rendering-perf branch from c7d98f3 to 7325c51 Compare February 21, 2026 15:58
@github-actions github-actions Bot added pkg: astro Related to the core `astro` package (scope) and removed pkg: astro Related to the core `astro` package (scope) labels Feb 21, 2026
@ascorbic ascorbic force-pushed the bench/rendering-perf branch 2 times, most recently from 16c98bd to b529461 Compare February 21, 2026 17:48
@ascorbic ascorbic force-pushed the bench/rendering-perf branch from b529461 to 99e4c80 Compare February 21, 2026 18:16
@ascorbic ascorbic changed the title Bench/rendering perf perf: SSR rendering optimizations (up to 2x on data-heavy pages) Feb 21, 2026
@ascorbic ascorbic changed the title perf: SSR rendering optimizations (up to 2x on data-heavy pages) perf: SSR rendering optimizations Feb 21, 2026
@ascorbic ascorbic marked this pull request as ready for review February 21, 2026 18:44
if (elements.length <= 1) return elements;
const seen = new Set<string>();
return elements.filter((item) => {
const key = JSON.stringify(item.props) + item.children;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not that this was added in this PR so it could be changed in another one, but using JSON.stringify seems dangerous here. Two elements with exactly the same properties but in a different order should not be deduplicated?

> a = JSON.stringify({foo: 1, bar: 2})
'{"foo":1,"bar":2}'
> b = JSON.stringify({bar: 2, foo: 1})
'{"bar":2,"foo":1}'

I don't think we are normalizing the head elements to have a consistent property ordering.

We already have a dependency on deterministic-object-hash for that, so it should be just a function swap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll sort the keys. It's not a particularly hot path, but deterministic-object-hash is still probably overkill.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

deterministic-object-hash

I found out that this package uses node.js APIs, so we can't use it anyway :/ I believe uses node:crypto, last time I checked

Comment on lines +93 to +95
flushers[j] = createBufferedRenderer(destination, (bufferDestination) => {
return renderChild(bufferDestination, children[i + 1 + j]);
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If we add a detection logic for the types of child that are known to never return a promise we could avoid the allocations for them even when there is a Promise before them. Since the most common child (a plain string) is one such case this might make for a big improvement for pages that do have async parts.

(also could be a separate PR)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We will be able to do that with queued rendering, which enabled batching

Copy link
Copy Markdown
Member

@ematipico ematipico left a comment

Choose a reason for hiding this comment

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

No particular concerns with the code, but I think we should reword the changeset to make it more friendly for our end- users

Comment thread .changeset/fast-wolves-render.md Outdated
Comment on lines +7 to +12
Refactors the internal rendering pipeline to avoid unnecessary object allocations and reduce overhead in hot paths:

- Replaces `Object.prototype.toString.call()` with `instanceof` for HTML string detection
- Reorders the `renderChild` type dispatch to check strings first (most common case)
- Eliminates O(N²) head element deduplication by using a `Set`
- Renders array children and template expressions directly to the destination without buffering when all children are synchronous, falling back to buffered rendering only when a `Promise` is encountered
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not sure this is relevant to our end-users. If we want to talk about performance, perhaps we should say where users will these benefits: e.g. pages with more than 50 components, pages with many strings, etc. Essentially, we should give them something that they can quantify

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, makes sense

if (flusher) {
const result = flusher.flush();
return result.then(() => {
let k = 0;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why is k defined outside the closure? can't we define it before the while loop, inside the closure?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's in the closure, but needs to be outside of iterate so that it still increments when iterate calls itself

Comment on lines +9 to +14
// 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;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

oh yeah, this was a perf suggestion that the AI agent suggested while I worked on the queued rendering. Short-circuit to simple node types first

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yeah, these sort of ones are so obvious once you see them! Cheap/common checks first.

Comment on lines +93 to +95
flushers[j] = createBufferedRenderer(destination, (bufferDestination) => {
return renderChild(bufferDestination, children[i + 1 + j]);
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We will be able to do that with queued rendering, which enabled batching

@ascorbic ascorbic merged commit f6473fd into main Feb 23, 2026
43 of 44 checks passed
@ascorbic ascorbic deleted the bench/rendering-perf branch February 23, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg: astro Related to the core `astro` package (scope)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants