Streaming HTML for Asynchronous DOM Updates Without JavaScript
Web applications deliver the best user experience when pages load quickly and display additional data as it becomes available. Traditional methods using JavaScript to display data asynchronously are powerful but add complexity compared to classic server-side rendering. Declarative Shadow DOM enables developers to display unordered content using templates and slots. HTTP streaming responses allow developers to send incremental parts of an HTML page to the user as data becomes ready. This article demonstrates a Go application using declarative Shadow DOM with HTTP streaming responses to load pages fast and show additional data without JavaScript.
Developers strive to build responsive web applications for optimal user experience. Users expect pages to load swiftly, but this can be challenging when pages require data from slow sources or perform computationalyl intensive operations. In such cases, developers may initially load a page with basic styling and fast-loading data, then update the page asynchronously when slower data is available.
Updating a page as data arrives almost always requires JavaScript. Single-page applications (SPAs) are most common, but newer frameworks that interact well with server-side rendering, like Remix, Next.js, HTMX, or Turbo, are becoming more prevalent. However, each JavaScript solution introduces additional complexity to the application.
With the evolution of declarative Shadow DOM and streaming HTTP bodies, developers have new techniques to update pages asynchronously without JavaScript. These techniques can be applied to server-side rendered applications to improve responsiveness while keeping complexity low.
JavaScript-Based Approaches
Single-Page Applications
Single-page applications, such as those built with React, remain the most common architecture developers use for asynchronous page updates. SPAs are widely used and familiar to most developers. They are highly flexible, enabling rich and responsive user interactions.
However, SPAs bring considerable complexity. An SPA is an application independent of the backend. The complexity of building and maintaining an SPA is similar to that of a mobile application. SPAs must be thoroughly tested independently and integrated with the application backend.
It is difficult to find developers proficient in both SPAs and backend frameworks, so developers are often divided into frontend and backend teams. This separation increases communication overhead, creates dependencies between teams, and reduces understanding of how system functionality works.
Server-Side Rendered React
HTTP streaming bodies allow browsers to render parts of an HTML document before the entire response is received. Newer frameworks like Remix and Next.js leverage this capability, rendering most of the page on the server and streaming over the same connection as other data becomes available. Each framework also provides client-side JavaScript to read the streamed data and update the DOM.
These frameworks are simpler than SPAs but retain SPA flexibility, allowing developers to run arbitrary code in the browser. They are somewhat complex because much of the application's code must be able to run in both the browser and the backend. Additionally, the backend must be written in JavaScript or a language that compiles to JavaScript, which may not be ideal for many teams.
Lightweight Frontend Libraries
Lightweight frontend libraries like HTMX and Turbo offer a solution that allows developers to make HTTP calls using HTML attributes and replace parts of the DOM with the response. They can add a layer of lightweight interactivity to server-side rendered applications without custom JavaScript code. These libraries are simple but offer limited interactivity compared to JavaScript solutions. They are also challenging to test because testing must be done by driving the browser using frameworks like Cypress or Playwright.
An Alternative Approach
Declarative Shadow DOM
The template and slot features introduced by Shadow DOM add reusable templates to HTML. Unfortunately, until recently, the only way to use Shadow DOM was via the JavaScript attachShadow function. Modern browsers now support declarative Shadow DOM, which allows developers to construct shadow roots directly in HTML.
The example below shows a shadow root declared directly in HTML. The template element contains a slot tag filled by an element with a slot attribute.
<section>
<template shadowrootmode="open">
<slot name="heading"></slot>
</template>
<h1 slot="heading">Hello world</h1>
</section>
The browser renders this as follows.
<section>
#shadow-root
<h1 slot="heading">Hello world</h1>
</section>
Adding Streaming
Templates facilitate asynchronous page loading by using content from one element to fill a slot in another. Suppose we are building an application where retrieving a user's email address is a slow operation. We define markup as follows.
<header><!-- header content --></header>
<footer><!-- footer content --></footer>
<main>
<template shadowrootmode="open">
<slot name="heading">
<h1>Loading</h1>
</slot>
</template>
<!-- additional main content -->
<h1 slot="heading">Welcome, user@example.com!</h1>
</main>
This way, we can render the entire page with fast-loading data before the user's email is displayed. Using HTTP streaming, we first send everything before the h1 tag to the browser so it can render a partial view of the page.
<header><!-- header content --></header>
<footer><!-- footer content --></footer>
<main>
#shadow-root
<h1>Loading</h1>
<!-- additional main content -->
Once we retrieve the user's email, we send it over the stream and close the connection so the user can see the rest of the page.
<header><!-- header content --></header>
<footer><!-- footer content --></footer>
<main>
#shadow-root
<h1 slot="heading">Welcome, user@example.com!</h1>
<!-- additional main content -->
</main>
Styling the Shadow DOM
A powerful aspect (or a frustrating one, depending on your use case) of Shadow DOM is that each shadow root encapsulates its own styles. This means elements rendered inside the shadow DOM are not affected by global CSS styles, and styles applied to a given shadow root do not affect elements outside it. If all your styles are confined to components, this might be ideal, but in practice, most applications have some global styles that should apply everywhere.
Ongoing discussions among specification maintainers seek the best way to allow global styles to affect templated HTML documents, but currently, the recommended approach is to replicate global styles within each shadow root using a link tag referencing the same file as in the head.
<link rel="stylesheet" href="style.css">
<header><!-- header content --></header>
<footer><!-- footer content --></footer>
<main>
<template shadowrootmode="open">
<link rel="stylesheet" href="style.css">
<slot name="heading">
<h1>Loading</h1>
</slot>
</template>
<!-- additional main content -->
<h1 slot="heading">Welcome, user@example.com!</h1>
</main>
Since the browser has already fetched and parsed the global styles file, there is no performance penalty. The downside is that this is cumbersome for developers, and global styles do not apply across light/shadow DOM boundaries.
Advantages
The primary advantage of this approach is simplicity. Most application code is identical to that of a raw server-side rendering (SSR) application. As with other SSR, debugging is simpler because all code runs in the same place.
Additionally, testing the application is straightforward. Tests can be written like those for any other server-side rendered application, inspecting the final output of template rendering without worrying about streaming.
Applications built with this method are fast. All data is fetched in a single request and displayed on screen as it becomes ready. In a traditional SPA, we must wait for the initial request to render in the browser before making additional requests to fetch more data.
Disadvantages
A major drawback of this approach is that it is not yet widely adopted. Few frameworks use the technologies involved, and some templating languages do not support it. Because declarative Shadow DOM is relatively new, documentation is sparse.
Furthermore, applications built with this method cannot achieve the same level of interactivity as SPAs. After the initial HTTP request closes, the application will behave like a traditional SSR application.
Error handling with this method is also quite difficult. It keeps the initial HTTP connection to the page open until slow data loads. The longer the connection stays open, the higher the risk of interruption, potentially leaving the page in a partially loaded state. In such cases, the application must find a way to display an error to the user.
Example Using Go
Next, we consider an example application and codebase using declarative Shadow DOM and streaming HTTP responses to see how this technique can be applied in practice. Most HTTP servers support HTTP streaming out of the box, so we should be able to build the example with any language/framework combination. Go is a natural choice due to its built-in concurrency primitives, and Go's templates incrementally write output to a Writer as the template is parsed.
Buffered Writer
Go's http.Server writes response bodies to the connection via a 4kB buffered writer. Even if we incrementally write the result of parsing a template, the result is not sent to the user until 4kB of data is written.
To ensure updates are sent to the user as soon as possible, we must flush the buffered writer data before waiting for long-running operations. This renders as much of the page as possible to the user while waiting for slower data to resolve.
The http.ResponseWriter interface does not have a flush method, but most implementations do (including the one provided by http.Server). If possible, use the Flush method on http.ResponseController to safely flush the response writer.
Note that browsers or other network layer eleemnts may also buffer the response body stream, so flushing does not guarantee they will actually send data to the user. Applications using this method should be tested on production-like infrastructure to ensure they work correctly. In practice, this approach tends to be well-supported.
A Simple Handler
Our example application has an index handler that displays a simple message. To illustrate how our Go server streams slow responses, we add an artificial one-second delay for the message provider.
resultChan := make(chan []string)
go func() {
resultChan <- dataProvider.FetchAll()
}()
_ = templateRenderer.Render(w, resources, "index", viewModel{Content: deferred.New(w, resultChan)})
In the background, we wait for the messages and send them to the channel once ready. Before passing the channel to the template model, it is wrapped in a deferred object.
Deferred Object
The deferred struct accepts a writer and a channel as properties. Once GetValue is called, the deferred struct flushes the writer (using the http.ResponseController method as described above), then waits for the channel's result and returns it.
type Deferred[T any] struct {
writer http.ResponseWriter
channel chan T
}
func (d Deferred[T]) GetValue() T {
d.flush()
return <-d.channel
}
func (d Deferred[T]) flush() {
_ = http.NewResponseController(d.writer).Flush()
}
Flushing allows the template to be rendered before waiting for the slow message, meaning the user can view content while waiting for the message.
Streaming Template
As described above, the template declaratively creates a shadow root and includes global styles. The slot contains placeholder content that is rendered until the slotted content is positioned later. We call GetValue here so that the writer is flushed before the slow message is sent to the user.
Once the message is received, GetValue returns, and the rest of the template (including the slotted content) is rendered for the user.
<template shadowrootmode="open">
<link rel="stylesheet" href="/static/style/application.css">
<!-- header content -->
<section>
<slot name="content">
<h2>Wait for it...</h2>
</slot>
</section>
</template>
{{ $items := .Content.GetValue }}
<div slot="content">
<h2>
Success!
</h2>
<ul class="bulleted">
{{range $item := $items}}
<li>{{$item}}</li>
{{end}}
</ul>
</div>
That's it! The rest of the application is handled by a standard Go server.