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.
Updating the page asynchronously typically involves JavaScript, which introduces complexity.
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.
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.
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.
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.
The Declarative Shadow DOM allows parts of the web page to be encapsulated and managed independently, reducing the need for JavaScript.
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.
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
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!
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.
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.
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.
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.
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.
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.
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.
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.