Introduction
Modern web performance issues often come from delayed rendering rather than network speed. Client-side hydration and heavy JavaScript pipelines can prevent browsers from showing content as early as they could. Several solutions already exist, like SSR or SSG to mitigate this. They help a lot, but in practice, SSG pages need to be rebuilt when content changes, and SSR alone cannot make expensive time-consuming operations like database queries or API calls available in request response bodies.
This becomes more complex with dynamic data, for example a product page where stock or availability can change. Combining good SEO with asynchronous and incremental content is still difficult. Pages are often fast and static, or dynamic but expensive to render and crawl.
This …
Introduction
Modern web performance issues often come from delayed rendering rather than network speed. Client-side hydration and heavy JavaScript pipelines can prevent browsers from showing content as early as they could. Several solutions already exist, like SSR or SSG to mitigate this. They help a lot, but in practice, SSG pages need to be rebuilt when content changes, and SSR alone cannot make expensive time-consuming operations like database queries or API calls available in request response bodies.
This becomes more complex with dynamic data, for example a product page where stock or availability can change. Combining good SEO with asynchronous and incremental content is still difficult. Pages are often fast and static, or dynamic but expensive to render and crawl.
This article looks again at HTML streaming, inspired by Chris Coyier’s article Streaming HTML, as one possible answer to this problem. It is based on an experimental project called HTMS, which explores progressive HTML rendering and the trade-offs that come with it.
Context: modern performance problems
Over the years, web applications have moved more and more logic to the client side. This brought a lot of flexibility, but also new performance issues. Even when the server responds quickly, users often wait for JavaScript to download, parse, and execute before seeing meaningful content.
Hydration is one of the main sources of this delay. HTML may arrive early, but it is often treated as inactive until JavaScript takes control. During this time, the page can look empty or incomplete, even if the data is already available.
This also creates an SEO and accessibility problem. With a typical SPA, the initial HTML can be very small, and most of the real content is generated after JavaScript runs. For example, a screen reader can receive something like this:
<html lang="en">
<head>
<title>Product</title>
<meta name="description" content="Buy our product" />
<script type="module" src="/assets/app.js"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
In this case, the important content is not in the HTML at all. It will appear only after the client-side code runs. Some crawlers can execute JavaScript, but it is slower, less reliable, and it can hide problems until later.
From a performance point of view, this affects key metrics like First Contentful Paint, Largest Contentful Paint, and Cumulative Layout Shift. From a user point of view, it simply feels slow.
Server-side rendering (SSR) improves early rendering by sending a more complete HTML response. JavaScript is then used to fetch or update data using fetch, WebSockets, or Server-Sent Events. The limitation is that dynamic data is often not part of the initial response, which makes SEO more fragile.
Static site generation (SSG) produces fully static HTML that is excellent for SEO, but requires rebuilds when content changes. This works well for mostly static pages, but is less adapted to frequently updated or live data.
Rediscovering HTML streaming
HTML streaming is not a new idea. From the beginning of the web, servers have been able to send responses progressively, and browsers have always been able to render HTML as it arrives.
The core idea is simple: instead of waiting for all the data to be ready, the server starts sending HTML immediately. The browser parses it, builds the DOM, and paints content step by step. This allows users to see something useful very early.
For people who have been around the web for a long time, this is not a new feeling. It is similar to how images loaded on slow connections, like 56k modems. Images appeared line by line, giving an early preview instead of a blank screen.
In practice, streaming is often replaced today by a single HTML response delivered in one chunk, followed by client-side rendering. This shifts most of the work to JavaScript and delays the first meaningful paint.
Streaming takes a different approach. The server sends an initial HTML skeleton, then continues the response with more HTML fragments when data becomes available.
Instead of treating HTML as a static document, it becomes a progressive delivery format again, with the browser doing what it does best. Instead of sending an almost empty HTML document and waiting for JavaScript, a streamed response can look like this:
<html lang="en">
<head>
<title>Perf news</title>
<meta name="description" content="Last news" />
</head>
<body>
<header>
<h1>Perf news</h1>
<nav>
<a href="/">Home</a>
<a href="/archives">Archives</a>
<a href="/about">About</a>
</nav>
</header>
<main>
<section>Some contents...</section>
<section>Some contents...</section>
<section>Some contents...</section>
At this time, the browser can render the header, navigation, and some contents, without waiting for client-side JavaScript. Later in the same response, once the data is ready, the server continues sending chunks:
<section>More contents...</section>
<section>More contents...</section>
</main>
<footer>CopyLeft</footer>
</body>
</html>
This approach is already very good. The response is progressive and does not block rendering. The page structure appears early, which improves the user experience and works very well for SEO.
All the content is delivered as part of the response. There is no hidden state and no client-side rehydration. What the browser receives is exactly what users and crawlers see.
The main limitation is flexibility. The layout follows a strict sequence and HTML must arrive in the right order. Updating specific parts of the page later can be difficult.
HTMS is an attempt to keep these benefits, while reducing the layout constraints. This will be explored in the next section.
From a Rust proof of concept to a JavaScript experiment
After reading Chris Coyier’s article and its references, and looking at what already existed around HTML streaming, it felt like a good topic to explore in practice.
At the same time, I was looking for a concrete project to practice Rust. It is a language I enjoy a lot, but I do not often get the opportunity to use it. HTML streaming looked like a good fit for a small but real experiment.
The first version of HTMS was a Rust proof of concept, focused on sending HTML as early as possible. It helped validate the idea, but it was limited by my velocity in Rust.
As the experiment grew, JavaScript became more practical to explore new APIs. It also has a larger community, which makes experimentation easier. Today, the Rust version is a minimal reference. The JavaScript version is where most of the work happens.
Progressive placeholders and streamed updates
Basic HTML streaming already improves rendering, but it quickly reaches its limits when pages become more dynamic. Once the layout grows, keeping a strict sequential order becomes difficult.
The idea behind HTMS is to keep the same progressive delivery model, while making updates more flexible. Instead of sending only linear HTML, the server can describe how later fragments should update parts of the page.
Placeholders play a central role. They are real HTML elements rendered early, with a clear position in the layout. When new data becomes available, these placeholders are replaced or extended with final content.
Because placeholders exist in the initial HTML, layout shifts are easier to avoid. The browser already knows where content will appear, and updates do not require a client-side rehydration step.
This keeps the page fully usable during loading, while still allowing asynchronous data to arrive later in the same response.
Example: building a product page with HTMS
To illustrate how HTMS works, let’s look at a product page. This is a good use case for SEO and performance because we want the main content to be indexed, but some parts like stock or reviews might take more time to fetch.
In the initial HTML template, we define placeholders using the data-htms attribute. These will be shown immediately while the server gets the dynamic data.
<!-- index.html -->
<main>
<h1>Sales Dashboard</h1>
<section class="dashboard-grid">
<article class="card">
<h2>Top product (this month)</h2>
<div data-htms="topProductCurrent">Loading...</div>
</article>
<article class="card">
<h2>Top product (last month)</h2>
<div data-htms="topProductLast">Loading...</div>
</article>
<article class="card">
<h2>Recent reviews</h2>
<div data-htms="topReviews">Loading reviews...</div>
</article>
</section>
</main>
On the server side, we have async functions for these placeholders.
// index.ts
export async function topProductCurrent() {
const product = await db.products.getTopMonthly();
return `
<div class="product-highlight">
<strong>${product.name}</strong>
<span>${product.sales} sales</span>
</div>
`;
}
export async function topProductLast() {
const product = await db.products.getTopLastMonth();
return `
<div class="product-highlight">
<strong>${product.name}</strong>
<span>${product.sales} sales</span>
</div>
`;
}
export async function topReviews() {
const reviews = await api.getRecentReviews();
return `
<ul class="reviews-list">
${reviews.map(r => `<li><strong>${r.user}</strong>: ${r.comment}</li>`).join('')}
</ul>
`;
}
Finally, we use the HTMS pipeline to link the template and the functions together and stream the response to the client.
// server.ts
import { createHtmsFileModulePipeline } from 'htms-js';
server.get('/', (req, res) => {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
createHtmsFileModulePipeline('index.html').pipeTo(res);
});
When a user visits the page, they instantly see the “Loading…” messages. As each async function completes on the server, the corresponding HTML fragment is streamed and the placeholder is replaced in the browser.
All this happens in a single HTTP request, without any client-side framework or complex hydration.
One of the most important benefits is that the final response is a complete HTML document for crawlers or browsers. Even if parts arrived later in the stream, the final “view source” looks something like this:
<main>
<h1>Sales Dashboard</h1>
<section class="dashboard-grid">
<article class="card">
<h2>Top product (this month)</h2>
<div data-htms-uuid="xxx-001">Loading...</div>
</article>
<article class="card">
<h2>Top product (last month)</h2>
<div data-htms-uuid="xxx-002">Loading...</div>
</article>
<article class="card">
<h2>Recent reviews</h2>
<div data-htms-uuid="xxx-003">Loading reviews...</div>
</article>
</section>
</main>
<!-- 200ms later the first streamed chunk is appended in the same response, order does not matter -->
<htms-chunk uuid="xxx-002">
<div class="product">
<strong>Classic Boots</strong>
<span>980 sales</span>
</div>
</htms-chunk>
<!-- 200ms later, another chunk arrives -->
<htms-chunk uuid="xxx-001">
<div class="product">
<strong>Ultra Sneakers</strong>
<span>1,240 sales</span>
</div>
</htms-chunk>
<!-- 500ms later the last chunk arrives -->
<htms-chunk uuid="xxx-003">
<ul class="reviews">
<li><strong>Alice</strong>: Amazing quality!</li>
<li><strong>Bob</strong>: A bit slow on delivery but worth it.</li>
</ul>
</htms-chunk>
The <htms-chunk> elements are custom elements (Web Components) that the HTMS mini (~2KB minified) runtime uses to replace placeholders.
Because these chunks are part of the initial HTTP response, search engines can index the full content. The browser’s DOM will eventually reflect the merged state, but the “raw” response has everything needed for SEO.
Technically, it would be possible to avoid the JS runtime by using the new Declarative Shadow DOM API. But this won’t be explored here. Maybe a future contribution from the community?
Performance results and Lighthouse metrics
One of the goals of this experiment was to validate whether HTML streaming with progressive updates could work well with common performance metrics.
A public demo is available at https://htms.skarab42.dev to see this in action. Despite serving dynamic content incrementally, the page achieves very strong Lighthouse results, including a 100/100 score for Performance. This is not an empty or static page; real content is streamed and rendered progressively.

These results mainly come from how early the browser can start rendering. The initial HTML arrives quickly, contains meaningful structure, and does not wait for client-side JavaScript to execute.
As a result, First Contentful Paint and Largest Contentful Paint happen very early. Layout stability is also easier to control. Because placeholders define the layout from the start, content updates do not introduce unexpected shifts, which keeps Cumulative Layout Shift low.
HTMS does not try to optimize Lighthouse directly. Instead, the metrics improve as a natural consequence of working with the browser rendering model, rather than against it.
By prioritizing the delivery of HTML and letting the browser handle the assembly, we align with the platform’s strengths. Writing these lines makes me realize that we should definitely explore the Declarative Shadow DOM path to embrace web standards even further.
Note on HTTP streaming and compression
HTML streaming is built on standard HTTP features. The response is sent using chunked transfer encoding:
Transfer-Encoding: chunked
This works well with common compression algorithms like gzip or Brotli. HTML chunks are compressed as they are streamed, without preventing progressive rendering in the browser.
The live demo shows this behavior in practice: content is streamed, compressed, and rendered incrementally, without waiting for the full response to complete.
SEO and accessibility considerations
Because HTMS relies on server-rendered HTML, SEO comes naturally. All content is part of the HTTP response, even if some parts arrive later in the stream. There is no dependency on client-side JavaScript for crawlers to discover or index the page.
This model also changes how failures behave. If JavaScript fails or is disabled, the page still contains meaningful HTML. If an error happens during streaming, what has already been sent remains readable.
This is both a benefit and a constraint, since traditional error handling like returning a clean 500 page is no longer possible once streaming has started.
Accessibility benefits from the same progressive model. Placeholders are real HTML elements that exist in the DOM from the beginning. When content is updated, HTMS automatically adds ARIA attributes when possible, depending on the chosen injection mode.
This allows updates to be announced using standard semantics like aria-live or role="status", without requiring complex client-side logic. HTMS builds on existing HTML and ARIA behavior, keeping accessibility predictable and easier to reason about.
Trade-offs and limitations
HTML streaming comes with real benefits, but also important trade-offs.
In HTMS, errors coming from async functions used in templates are captured. Depending on the configuration, they can be rendered inside the streamed HTML or replaced with fallback content. If an error happens before the first chunk is sent, the server can still return a regular 500 response. Once streaming has started, error handling must happen inside the stream itself.
Streaming also depends on the transport path. Some clients, proxies, or CDNs may buffer responses, reducing or completely removing the benefits of progressive delivery.
Structural constraints are another limitation. Placeholders must exist early in the HTML, and some layout decisions need to be made before all data is available. In practice, this can be mitigated, or even avoided, by combining HTMS with tools like HTMX or other client-side solutions that can take over after the initial page has been served. This kind of combination is not only possible, but encouraged.
More generally, HTML streaming works best when combined with other approaches like SSR, SSG, or SSE, to get the best of each world rather than relying on a single technique.
This list is far from exhaustive. I expect the community to find more limitations and challenge existing solutions.
Conclusion
HTML streaming is not a new technique, but it is still surprisingly relevant. Many modern performance problems come from working against the browser instead of letting it render HTML as early as possible.
HTMS is an experimental project that explores this space. It is not meant to replace existing solutions, but to show how streaming HTML can work well with modern requirements like performance, SEO, and accessibility.
In practice, the best results come from combining approaches. HTML streaming can be used alongside SSR, SSG, SSE, or tools like HTMX, each one handling what it does best.
More importantly, this work is an invitation to experiment. There is still a lot to explore in this area, and many open questions to challenge as the web platform continues to evolve. For example, caching is a topic that needs to be explored and completely avoided in this article.
I really enjoyed working on this project. I do not know yet if I will push it much further, but everything is in place for curious people to experiment with HTML streaming in a simple way.
If this article makes you want to explore, try, or even break things, then it already did its job.
That said… this Declarative Shadow DOM thing is still very tempting.