Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@justinfagnani
Copy link
Collaborator

@justinfagnani justinfagnani commented Sep 25, 2025

This gets rid of generators completely from the SSR code in favor of a thunk/trampoline approach where render functions can return thunks that are called to continue rendering. This keeps our ability to render synchronously or asynchronously, while improving perf.

Benchmarks from #5103 are merged into a separate branch https://github.com/lit/lit/tree/ssr-thunks-benchmarks and show an 18x improvement compared to main.

This approach works by mapping a template's opcodes array into an array of strings or thunks and performing all render state access and mutation in those thunks. Consumers must iterate the array and when they get a thunk, they have to call it and process the return value. The return value can be undefined, a string, another render result, or a promise.

The thunk representation converts recursion and generators into a kind of continuation, and this appears to eliminate a huge amount of overhead.

Like the generator approach, synchronous consumers can throw when they encounter Promises, so we can implement renderToString() and continue to render synchronously inside of React.createElement patches, etc.

Asynchronous consumers can pause evaluation simply by not calling the next thunk yet. They must await the promises return by thunks, and can pause when they receive backpressure signals, etc. from downstream consumers.

The change is mostly backwards compatible. render() continues to return RenderResult, which is still an iterable of strings or promises. A new function called renderThunked() is added that returns a ThunkedRenderResult, which is an array of strings of thunks. render()callsrenderThunked()` and wraps the result in an iterable that uses a trampoline to read through the result for a small overhead.

collectResult(), collectResultSync(), and RenderResultReadable have been updated to accept both RenderResults and ThunkedRenderResults. Users who are already using those to consumer renders can just change their render call from render() to renderThunked() to get the best performance. All existing users will get a signification improvement either way though.

Further optimizations are likely possible. One idea is to reduce the number of closures produced by making a Renderer class that keeps the current render state on a stack and produces new values when calling next. Because of this, the ThunkedRenderResult type might not be very stable going forward. We may see a few major versions go by as we settle this down - this is labs after all!

One breaking change here is to the return type of the ElementRenderer methods. They now directly return a ThunkedRenderResult. This may be a good stable API to settle on, since if they return iterables of strings, we have to chain iterables (which we did with yield* previously) or change the return type to support iterables of iterables and make nested iterables responsible for kicking off execution of their render when next() is called - essentially a manual generator.

Custom ElementRenderers are more rare than direct SSR render() users, so it may be worth some churn here to land the drastic performance improvements ASAP, then adjust APIs in future major versions if needed.

@changeset-bot
Copy link

changeset-bot bot commented Sep 25, 2025

🦋 Changeset detected

Latest commit: 6a7220c

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

This PR includes changesets to release 4 packages
Name Type
@lit-labs/ssr Major
@lit-labs/ssr-react Patch
@lit-labs/eleventy-plugin-lit Patch
@lit-labs/testing 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

@github-actions
Copy link
Contributor

github-actions bot commented Sep 25, 2025

📊 Tachometer Benchmark Results

Summary

nop-update

  • this-change, tip-of-tree, previous-release: slower ❌ 2% - 18% (0.35ms - 2.28ms)
    this-change vs tip-of-tree

render

  • this-change: 42.92ms - 51.89ms
  • this-change, tip-of-tree, previous-release: unsure 🔍 -5% - +4% (-1.04ms - +0.93ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -2% - +2% (-0.88ms - +0.77ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -5% - +60% (-0.77ms - +28.42ms)
    this-change vs tip-of-tree

update

  • this-change: 522.59ms - 530.79ms
  • this-change, tip-of-tree, previous-release: unsure 🔍 -9% - +0% (-4.02ms - +0.18ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -4% - +2% (-3.20ms - +1.43ms)
    this-change vs tip-of-tree
  • this-change, tip-of-tree, previous-release: unsure 🔍 -2% - +1% (-9.75ms - +3.55ms)
    this-change vs tip-of-tree

update-reflect

  • this-change: 505.16ms - 513.26ms
  • this-change, tip-of-tree, previous-release: unsure 🔍 -1% - +1% (-4.43ms - +5.54ms)
    this-change vs tip-of-tree

Results

this-change

render

VersionAvg timevs
42.92ms - 51.89ms-

update

VersionAvg timevs
522.59ms - 530.79ms-

update-reflect

VersionAvg timevs
505.16ms - 513.26ms-
this-change, tip-of-tree, previous-release

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
19.83ms - 21.43ms-unsure 🔍
-5% - +4%
-1.04ms - +0.93ms
unsure 🔍
-5% - +6%
-1.04ms - +1.17ms
tip-of-tree
tip-of-tree
20.11ms - 21.26msunsure 🔍
-5% - +5%
-0.93ms - +1.04ms
-unsure 🔍
-4% - +5%
-0.83ms - +1.08ms
previous-release
previous-release
19.80ms - 21.32msunsure 🔍
-6% - +5%
-1.17ms - +1.04ms
unsure 🔍
-5% - +4%
-1.08ms - +0.83ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
38.93ms - 42.01ms-unsure 🔍
-9% - +0%
-4.02ms - +0.18ms
unsure 🔍
-6% - +5%
-2.39ms - +2.03ms
tip-of-tree
tip-of-tree
40.96ms - 43.81msunsure 🔍
-1% - +10%
-0.18ms - +4.02ms
-unsure 🔍
-1% - +10%
-0.39ms - +3.86ms
previous-release
previous-release
39.07ms - 42.23msunsure 🔍
-5% - +6%
-2.03ms - +2.39ms
unsure 🔍
-9% - +1%
-3.86ms - +0.39ms
-

nop-update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
13.32ms - 14.64ms-slower ❌
2% - 18%
0.35ms - 2.28ms
slower ❌
0% - 15%
0.05ms - 1.92ms
tip-of-tree
tip-of-tree
11.96ms - 13.36msfaster ✔
3% - 16%
0.35ms - 2.28ms
-unsure 🔍
-10% - +5%
-1.29ms - +0.63ms
previous-release
previous-release
12.33ms - 13.65msfaster ✔
1% - 14%
0.05ms - 1.92ms
unsure 🔍
-5% - +10%
-0.63ms - +1.29ms
-
this-change, tip-of-tree, previous-release

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
36.16ms - 37.34ms-unsure 🔍
-2% - +2%
-0.88ms - +0.77ms
unsure 🔍
-4% - +0%
-1.68ms - +0.08ms
tip-of-tree
tip-of-tree
36.23ms - 37.38msunsure 🔍
-2% - +2%
-0.77ms - +0.88ms
-unsure 🔍
-4% - +0%
-1.62ms - +0.13ms
previous-release
previous-release
36.89ms - 38.20msunsure 🔍
-0% - +5%
-0.08ms - +1.68ms
unsure 🔍
-0% - +4%
-0.13ms - +1.62ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
82.25ms - 85.45ms-unsure 🔍
-4% - +2%
-3.20ms - +1.43ms
unsure 🔍
-3% - +2%
-2.39ms - +1.90ms
tip-of-tree
tip-of-tree
83.06ms - 86.41msunsure 🔍
-2% - +4%
-1.43ms - +3.20ms
-unsure 🔍
-2% - +3%
-1.56ms - +2.84ms
previous-release
previous-release
82.67ms - 85.52msunsure 🔍
-2% - +3%
-1.90ms - +2.39ms
unsure 🔍
-3% - +2%
-2.84ms - +1.56ms
-
this-change, tip-of-tree, previous-release

render

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
53.27ms - 75.27ms-unsure 🔍
-5% - +60%
-0.77ms - +28.42ms
unsure 🔍
-26% - +21%
-17.32ms - +13.88ms
tip-of-tree
tip-of-tree
40.85ms - 60.04msunsure 🔍
-42% - -1%
-28.42ms - +0.77ms
-faster ✔
4% - 43%
0.90ms - 30.18ms
previous-release
previous-release
54.92ms - 77.05msunsure 🔍
-22% - +27%
-13.88ms - +17.32ms
unsure 🔍
-2% - +64%
+0.90ms - +30.18ms
-

update

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
512.28ms - 521.87ms-unsure 🔍
-2% - +1%
-9.75ms - +3.55ms
unsure 🔍
-1% - +2%
-5.37ms - +8.66ms
tip-of-tree
tip-of-tree
515.56ms - 524.79msunsure 🔍
-1% - +2%
-3.55ms - +9.75ms
-unsure 🔍
-0% - +2%
-2.15ms - +11.64ms
previous-release
previous-release
510.31ms - 520.55msunsure 🔍
-2% - +1%
-8.66ms - +5.37ms
unsure 🔍
-2% - +0%
-11.64ms - +2.15ms
-

update-reflect

VersionAvg timevs this-change
vs tip-of-tree
tip-of-tree
vs previous-release
previous-release
this-change
521.10ms - 529.41ms-unsure 🔍
-1% - +1%
-4.43ms - +5.54ms
unsure 🔍
-1% - +1%
-5.67ms - +4.95ms
tip-of-tree
tip-of-tree
521.94ms - 527.45msunsure 🔍
-1% - +1%
-5.54ms - +4.43ms
-unsure 🔍
-1% - +1%
-5.21ms - +3.39ms
previous-release
previous-release
522.31ms - 528.91msunsure 🔍
-1% - +1%
-4.95ms - +5.67ms
unsure 🔍
-1% - +1%
-3.39ms - +5.21ms
-

tachometer-reporter-action v2 for Benchmarks

@github-actions
Copy link
Contributor

The size of lit-html.js and lit-core.min.js are as expected.

@justinfagnani justinfagnani marked this pull request as ready for review September 26, 2025 17:33
@justinfagnani justinfagnani mentioned this pull request Sep 26, 2025
@justinfagnani
Copy link
Collaborator Author

Benchmark results using @jimsimon's PR: #5103

main using generators:

Screenshot 2025-09-26 at 2 20 52 PM

This change using render()

Screenshot 2025-09-26 at 2 23 32 PM

This change using renderThunked():

Screenshot 2025-09-26 at 2 40 42 PM

This is about an 18x performance improvement for renderThunked() across the board 😱

render(), which is wrapping the thunked return type with a custom iterable for backwards compatibility, is about 14x faster than main.

These improvements are so large that they deserve a lot of investigation to verify that they're real. Tests do pass and this is a non-breaking change now, so we should be able to publish and get real-world results.


The code to adapt the benchmarks to thunks is in https://github.com/lit/lit/tree/ssr-thunks-benchmarks

@jimsimon
Copy link
Collaborator

I'll patch these changes into Reddit on Monday which should let us validate the performance improvement a bit. This is about 5 times faster than the "strings only" approach I currently have patched in, so we should see a shift in metrics.

@justinfagnani
Copy link
Collaborator Author

I need to push a new changeset file that marks this as a breaking change. I forgot about the ElementRenderer change when I make the first changeset.

Copy link
Contributor

@kyubisation kyubisation left a comment

Choose a reason for hiding this comment

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

LGTM 👍
Thank you very much for your work!
Looks very solid to me.

Copy link
Collaborator

@jimsimon jimsimon left a comment

Choose a reason for hiding this comment

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

LGTM! Thank you for taking this on!

Copy link
Collaborator

@rictic rictic left a comment

Choose a reason for hiding this comment

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

Woooooooot!

A number of minor issues, only one's really significant (mutating something we probably shouldn't).

@justinfagnani justinfagnani merged commit 5016b8f into main Sep 30, 2025
10 checks passed
@justinfagnani justinfagnani deleted the ssr-thunks branch September 30, 2025 17:40
@lit-robot lit-robot mentioned this pull request Dec 18, 2025
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.

4 participants