Home-Software Development-Enhancing Web Apps with Streaming HTML and Declarative Shadow DOM
Enhancing Web Apps Declarative Shadow DOM

Enhancing Web Apps with Streaming HTML and Declarative Shadow DOM

Developers strive for responsive web applications to provide the best user experience. Users expect pages to load quickly, even when data sources are slow or operations are computationally intensive. Traditionally, this is achieved by initially loading the page with basic styles and quick-loading data, then updating the page asynchronously as slower data becomes available.

Challenges with JavaScript Solutions

Updating the page asynchronously typically involves JavaScript, which introduces complexity.

Single Page Applications (SPAs)

SPAs, built with frameworks like React, are common for asynchronous updates. They allow for rich user interactions but are complex to build and maintain. SPAs require separate development for frontend and backend, leading to communication challenges and intra-team dependencies.

Server-Side Rendered React

Frameworks like Remix and Next.js use HTTP streaming bodies to render HTML incrementally. They simplify some aspects compared to SPAs but still require JavaScript for both backend and frontend, which may not be ideal for all teams.

Lightweight Frontend Libraries

Libraries like HTMX and Turbo enable HTTP calls and DOM updates using HTML attributes, adding interactivity without custom JavaScript. They are simpler but have limited interactivity and are difficult to test compared to JavaScript solutions.

Alternatives to JavaScript

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

Declarative Shadow DOM

The Declarative Shadow DOM allows parts of the web page to be encapsulated and managed independently, reducing the need for JavaScript.

HTTP Streaming

HTTP streaming sends updates from the server as they become available, rather than waiting for all data to load. This approach is effective in environments with unpredictable data fetching times.

While JavaScript solutions like SPAs, server-side rendered React, and lightweight frontend libraries offer flexibility and rich interactivity, they introduce complexity. The Declarative Shadow DOM and HTTP streaming provide simpler alternatives for asynchronous updates in server-side rendered apps, enhancing responsiveness without adding complexity.

A Different Approach: Declarative Shadow DOM and Streaming HTML

Declarative Shadow DOM

The template and slot features introduced by Shadow DOM add reusable templates to HTML. 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.

Here’s an example of a shadow root declared directly in HTML. The template element contains a slot tag populated by the element with the slot attribute:

        

Hello world

The browser renders this as follows:

        
#shadow-root

Hello world

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:

        

Loading

Welcome, user@example.com!

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:

        
#shadow-root

Loading

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:

        
#shadow-root

Welcome, user@example.com!

Styling the Shadow DOM

The shadow DOM 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.

To apply global styles within the shadow DOM, duplicate the global styles in each shadow root using a link tag that references the same files as in the head:

        

Loading

Welcome, user@example.com!

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 its simplicity. 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

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()
}  

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut elit tellus, luctus

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.

        
    
    
    

Wait for it...

{{ $items := .Message.GetOne }}

Success!

    {{range $item := $items}}
  • {{$item}}
  • {{end}}

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.

logo softsculptor bw

Experts in development, customization, release and production support of mobile and desktop applications and games. Offering a well-balanced blend of technology skills, domain knowledge, hands-on experience, effective methodology, and passion for IT.

Search

© All rights reserved 2012-2024.