BT

Facilitating the Spread of Knowledge and Innovation in Professional Software Development

Write for InfoQ

Topics

Choose your language

InfoQ Homepage Articles Streaming HTML – Asynchronous DOM Updates without JavaScript

Streaming HTML – Asynchronous DOM Updates without JavaScript

Key Takeaways

  • Web applications provide the best user experience when pages load quickly and display additional data as they become available.
  • Traditional approaches using JavaScript to display data asynchronously are powerful but add additional complexity compared to traditional server-side rendering.
  • The Declarative Shadow DOM allows developers to display out-of-order content using templates and slots.
  • HTTP streaming responses allow developers to send incremental elements of an HTML page to the user as data becomes available.
  • We show an example application in Go that uses the Declarative Shadow DOM with HTTP streaming responses to load pages quickly and display additional data as it becomes available without JavaScript.

Developers strive for responsive web applications to provide the best user experience. Web application users expect pages to load quickly, which can be difficult to achieve if a page requires data from a slow source or performs computationally intensive operations. In these cases, developers may initially load the page with basic styles and quick-loading data, then update the page asynchronously when slower data becomes available.

Updating the page when data becomes available almost always involves JavaScript. Single-page applications (SPAs) are most commonly used, but new frameworks that interact nicely with server-side rendering, like Remix, Next.js, HTMX, or Turbo, are becoming more common. However, each JavaScript solution introduces additional complexity to an application.

With the development of the Declarative Shadow DOM and streaming HTTP bodies, developers have new techniques to update a page asynchronously without JavaScript. These techniques can be applied in server-side rendered apps to increase responsiveness while keeping complexity low.

JavaScript Solutions

Single Page Applications

A single-page application (built with React, for example) is still the most common architecture that developers use to update a page asynchronously. SPAs have the advantage of being widely used and familiar to most developers. They are tremendously flexible, allowing for rich and responsive user interactions.

However, SPAs introduce significant complexity. An SPA is a separate application that is distinct from your backend. Building and maintaining an SPA is similar to the complexity of mobile applications. SPAs must be thoroughly tested in isolation and integration with the application backend.

It’s difficult to find developers who work proficiently in SPAs and backend frameworks, so developers are often divided into frontend and backend teams.

This separation introduces the need for added communication, more intra-team dependencies, and a lower understanding of how the system functions.

Server-Side Rendered React

HTTP streaming bodies allow browsers to render parts of an HTML document before receiving the entire response. Newer frameworks like Remix and Next.js take advantage of this feature by rendering most of the page on the server and streaming additional data along the same connection as it becomes available. Each framework also provides client-side JavaScript that reads the streamed data and updates the DOM.

These frameworks are simpler than SPAs but retain the flexibility of an SPA by allowing developers to run arbitrary code in the browser. They come with a bit of complexity, as much of the application’s code must be able to run both in the browser and on the backend. Additionally, the backend must be written in JavaScript or something that compiles to JavaScript, which may be undesirable for many teams.

Lightweight Frontend Libraries

Lightweight frontend libraries like HTMX and Turbo provide a solution that allows developers to use HTML attributes to make HTTP calls and replace part of the DOM with their response. They can add a light layer of interactivity to a server-side rendered application without custom JavaScript code. These libraries are simple, but their interactivity is limited compared to JavaScript solutions. They’re also difficult to test because they must be tested by driving the browser with a framework similar to Cypress or Playwright.

A Different 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 the Shadow DOM was through the attachShadow JavaScript function. Modern browsers now support the Declarative Shadow DOM, which allows developers to construct a shadow root directly in HTML.

The example below shows a shadow root declared directly in HTML. The template element contains a slot tag populated by the element with the 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 populating an element in one place with content from an element in another. Suppose we’re building an app where retrieving a user’s email address is a slow operation, and we define our 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 allows us to render the entire page with data that loads quickly before we render the user’s email. Using HTTP streaming, we’d first send everything before the <h1 slot="heading"> tag to the browser so the browser would 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 on the stream and close the connection, so the user sees the rest of the page’s content.

<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

One of the powerful (or maddening, depending on your use case) things about the shadow DOM is that each shadow root encapsulates its own styles. This means that elements rendered in the shadow DOM are not affected by global CSS styles, and styles applied in a given shadow root do not affect elements outside the shadow root. This may be desirable if all your styles are narrowly scoped to components, but in practice, most applications have some global styles that should be applied everywhere.

There’s an ongoing discussion among specification maintainers to find the best way to allow for global styles to affect templated HTML documents, but for now, the recommended approach is to duplicate global styles in each shadow root using a link tag that references the same files 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 style file(s), there’s no hit to performance. The downside is that it’s cumbersome for developers, and global styles won’t apply across light/shadow DOM boundaries.

Benefits

The main benefit of this approach is that it’s simple. Most of the application code is identical to a vanilla server-side rendered (SSR) application. Like other SSRs, debugging is simpler since all the code runs in the same place.

Additionally, the application’s tests are simple. They can be written just like the tests of any other server-side rendered application, looking at the final output of template renders and not worrying about streams.

Applications built with this approach are fast. All data is fetched in one request and displayed on the screen when ready. In a traditional SPA, we must wait for the initial request to be rendered in the browser and then make additional requests to fetch more data.

Drawbacks

One major drawback of this approach is that it’s not (yet) widely used. There aren’t many frameworks that use its techniques, and some templating languages do not support it. Since the Declarative Shadow DOM is relatively new, documentation is scarce.

Additionally, applications built with this approach don’t allow for the same interactivity as SPAs. Once the initial HTTP request closes, the application behaves like a traditional SSR application.

Error handling can also be difficult with this approach. It keeps the page’s initial HTTP connection open until the slower data loads. The longer the connection is open, the greater the risk that the connection will be interrupted, possibly leaving the page in a partially loaded state. In this case, applications must find a way to display an error to the user.

An Example Using Go

Next, we’ll consider an example application and codebase that uses the Declarative Shadow DOM and streaming HTTP responses to see how to apply this technique in practice. Most HTTP servers support HTTP streaming out of the box, so we should be able to build an example using any language/framework combination. Go is a natural choice because of its built-in http.Server, concurrency primitives, and because 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 through a 4kB buffered writer. Even if we incrementally write the result of parsing the template, the result will not be sent to the user until we’ve written 4kB of data.

To ensure that updates are sent to the user as fast as possible, we must flush the buffered writer’s data before waiting for a long-running operation. This will render as much of the page as possible for the user while waiting for slower data to resolve.

The http.ResponseWriter interface doesn’t have a flush method, but most implementations do (including the one provided by the http.Server). If possible, use the flush method on http.ResponseController to flush the response writer safely.

Be aware that the browser or other network layer elements might also buffer the response body stream, so flushes cannot guarantee that they will actually send data to the user. Applications using this approach should be tested on production-like infrastructure to ensure they behave correctly. In practice, the approach tends to be well supported.

A Simple Handler

Our example application has a single index handler that displays a simple message. To illustrate how our Go server can stream slow responses, we’ve added an artificial one-second delay to the message provider.

data := make(chan []string)
go func() {
	data <- provider.FetchAll()
}()

_ = websupport.Render(w, Resources, "index", model{Message: deferrable.New(w, data)})

In the background, we wait for the message and send it to a channel when ready. The channel is wrapped in a deferrable object (which we’ll discuss next) before it’s passed to the template’s model.

Deferrable

The deferrable struct accepts a writer and a channel as properties. Once GetOne is called, the deferrable flushes the writer (using http.ResponseController as discussed above) before waiting for a result from the channel and returning it.

type Deferrable[T any] struct {
	writer  http.ResponseWriter
	channel chan T
}

func (d Deferrable[T]) GetOne() T {
	d.flush()
	return <-d.channel
}

func (d Deferrable[T]) flush() {
	_ = http.NewResponseController(d.writer).Flush()
}

The flush allows the template to be rendered before waiting for the slow message, meaning the user can view content while waiting for the message.

Streamed Template

As discussed above, the template declaratively creates a shadow root and includes the global styles. The slot contains placeholder content, which is rendered until the slot is targeted later. We call GetOne so the writer is flushed before the slow message is sent to the user.

Once the message is received, GetOne returns, and the rest of the template, including the slot 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 := .Message.GetOne }}
<div slot="content">
    <h2>
        Success!
    </h2>

    <ul class="bulleted">
        {{range $item := $items}}
        <li>{{$item}}</li>
        {{end}}
    </ul>
</div>

And that’s it! The rest of the application consists of standard Go server boilerplate, but if you’re interested, check out the source code.

Conclusion

This article shows a practical example of how to use the Shadow DOM, template slots, and streaming response bodies to build a responsive application without JavaScript. The next time you want to add a bit of responsiveness to an application, consider using this approach before moving to a heavier JavaScript-based solution. It gives developers the simplicity, testability, and maintainability of a server-side rendered application while providing a better user experience.

About the Author

BT