Progressive enhancement and development

Progressive enhancement is often viewed as a desirable user-facing feature. But, could it also be seen as a developer-facing methodology for building sites? It's almost synonymous with Agile! 8 January 2021

# Preamble

Say you want to build a modern website, and need it in market quickly. You want to be agile. To avoid unnecessary development, you prioritise your backlog based on actual usage.

A survey on the internet and a discussion with your colleagues suggests one of Create-React-App , Next.js , or even Gatsby . Or alternatively something based on Vue.js , or even Svelte . All the modern "best practises". Yet it feels like there's a new library/framework everyday! What if it is outdated or abandoned by the time it gets launched? How will you be able to acquire/retain knowledgeable staff? Will it have wide browser support?

What's The Simplest Thing That Can Possibly Work? What's TSTTCPW that can scale up in complexity as necessary?

Then you recall the early Internet. There was this thing called progressive enhancement  that uses native browser capabilities, then layers on optional functionality depending on browser capabilities or user preferences.

That sounds very Agile! You ship a simple version early, then based on actual usage, progressively prioritise the enhancement of the most often used features, features that deliver the most business value. Progressive enhancement for users also equals progressive development on your part! Serendipitously, browser support is the largest possible, they wouldn't even need Javascript enabled. And performance will be great, as very little is expected from the browser at all.

But how hard is it to build?

# Static pages

You start with some simple static pages that don't have any interactivity: a description of your services, some frequently asked questions, and importantly your contact details (using tel: and mailto: of course). These are either dynamically or statically generated server-side using templating languages like Pug , or Marko . Navigation between these pages use anchor links <a href="/faq-page">faq</a>, something all browsers know how to do. It's TSTTCPW. You ship this, and everyone is excited you're getting new enquiries. Business is good!

But you notice performance isn't great. It turns out that not only the document object-model is replaced on every navigation, so does the JS and CSS object models! This unnecessarily adds 0.5-1.0s to the navigation time, reloading the exact same JS and CSS. Let's progressively enhance this for browsers with Javascript enabled. We intercept all clicks on these links, perform an ajax request to fetch the link, then replace the current page body with the new page body. Indeed this functionality and much more, is provided by Turbo Drive . With browser and CDN caching, performance is now on par with modern Single-Page-Applications and everyone is happy.

# Auxiliary content

Customer support identifies that there is some technical jargon on the site that could benefit from a glossary feature. Too easy, we'll just sprinkle some hyperlinks to a glossary page <a href="/glossary#jargon">jargon</a> with a description list. It's TSTTCPW and will work on Javascript-less browsers, though it requires users to hit the back-button to go back to where they came from.

This sub par experience can be progressively enhanced with Turbo Frames  by intercepting clicks on the link, lazily loading the contents, detecting the nearest ancestor frame, then replacing only the corresponding frame in the newly fetched page, without changing the URL. This is akin to an advanced version of HTML frames or iframes, such that the framed content is inline where the user expected it. (In this example, there is no mechanism to collapse back the glossary).

<!-- technical.html -->
<!-- additional tags ignored without JS -->
<p>
Lorem ipsum mumbo jumbo jargon
<turbo-frame id="jargon">
<a href="/glossary#jargon">?</a>
</turbo-frame>
</p>
<p>
Mumbo jumbo lorem ipsum greek
<turbo-frame id="greek">
<a href="/glossary#greek">?</a>
</turbo-frame>
</p>
<!-- glossary.html-->
<!-- appears as description list -->
<dl>
<dt>jargon</dt>
<dd>
<turbo-frame id="jargon">
A technical word
</turbo-frame>
</dd>

<dt>greek</dt>
<dd>
<turbo-frame id="greek">
A foreign word
</turbo-frame>
</dd>
</dl>

The loading of the first glossary page would incur a network delay penalty, but secondary glossary lookups will be fast, as the browser can use its cached copy. This is no different to a Single-Page-Application that might lazily load the glossary too. Below is what happens when the jargon frame has been replaced, after clicking on the link.

<!-- modified technical.html -->
<p>
Lorem ipsum mumbo jumbo jargon
<turbo-frame id="jargon">
A technical word
</turbo-frame>
</p>
<p>
Mumbo jumbo lorem ipsum greek
<turbo-frame id="greek">
<a href="/glossary#greek">?</a>
</turbo-frame>
</p>

# Form submissions

With increased traffic, the human-in-the-loop contact method is increasing workloads considerably, and you need to automate the laborious data entry. You set up a signup form, such that users can directly enter more structured data. Browsers have been able to perform form POSTs since forever. For a RESTful server, if the submission failed server-side validation, a 4xx response is generated, along with a new page describing the reason, such that users can make corrections and resubmit. If the submission succeeds, a 303 redirect is sent back for the user-agent to optionally request the success page (browsers do so by default). This is the standard PRG (Post/Redirect/Get)  flow. It's TSTTCPW and it requires no Javascript.

<!-- standard HTML form, no Javascript needed -->
<!-- the extra spans are for progressive enhancement -->
<form method="post" action="/signup">
<p>
<input name="username">
<span id="username-validation"></span>
</p>
<p>
<input type="submit" value="Submit">
</p>
</form>

There are two ways this can be progressively enhanced. The success scenario is the easiest. The standard PRG flow is already intercepted by Turbo Drive  much the same way as navigating through hyperlinks. This ensures that there are no full page reloads, as the PRG is done with ajax.

The failure scenario can be progressively enhanced with Turbo Streams , which is basically a server generated response that specifies how to CRUD DOM elements. A Turbo-submitted form includes the HTTP header Accept: text/html; turbo-stream which the server can use to enable the Turbo stream response, instructing the browser to modify the DOM to represent any validation errors. Without the header, the server reverts back to the standard 4xx response.

<!-- Turbo Stream response -->
<turbo-stream
action="replace"
target="username-validation">

<template>
<span id="username-validation">
That username has already been taken!
</span>
</template>
</turbo-stream>
<!-- form after modification by Turbo Stream -->
<form method="post" action="/signup">
<p>
<input name="username">
<span id="username-validation">
That username has already been taken!
</span>
</p>
<p>
<input type="submit" value="Submit">
</p>
</form>

# Midamble

Up to this point, we haven't written a single line of frontend Javascript, we've merely augmented the HTML with some extra tags, changed the backend slightly, and included 12kB of compressed Javascript  that we did not have to maintain. The site operates on old browsers without Javascript, but progressively enhances to be as fast as any modern Single-Page-Application on capable browsers.

You pause to wonder how many people actually browse without Javascript, but recall that in 100% of browsers, there is no Javascript running (other than inline or blocking Javascript) in the time gap between when the HTML is received to when the Javascript is received. On slow internet connections, progressive enhancement on top of native browser capabilities means that users can immediately start interacting with the site they can see without delay.

The core business functionality are essentially complete. Users can access the contextual information they need, and submit forms. Anything else from here are merely delighters . It's a good time to give yourself a pat on the back for all the work you did not do to get here.

  • You did not require much out of browsers, and thus have large browser support.
  • You did not duplicate routing on the server and client.
  • You did not duplicate validation on the server and client.
  • You did not have to worry about writing reducers and selectors, to synchronise client-side and server-side state (a common source of bugs).
  • You did not have to worry about deploying a new version mid-way through a user session, as they will upgrade automatically on their next action.
  • You did not have to rely on client-based analytics, as traditional server logs already provide basic analytics.

# Interactive updates

Business is booming. You have lots of signups, and some popular usernames are frequently re-requested. Users are frustrated that they have to submit the entire form just to see if their chosen username is available. Let's delight them with an automatic check for existing usernames as they type.

Enter Stimulus , an 8kB  companion to Turbo. By annotating the signup HTML with additional data attributes , Stimulus can associate appropriate controllers. In this example, whenever there is an input event on the username input, the checkAvailable method on the signup controller is invoked. This momentarily alters the form submit to another endpoint that returns Turbo Stream responses to alter the DOM to indicate if/when a username has already been taken. When the normal submission is clicked, the normal form submission takes place.

<!-- signup form -->
<form
method="post"
action="/signup"
data-controller="signup"
data-signup-action-value="/signup-validation">

<p>
<input
name="username"
data-action="input->signup#checkAvailable">

<span id="username-validation"></span>
</p>
<p>
<input
type="submit"
value="Submit"
data-signup-target="submit">

</p>
</form>
// signup_controller.js
import { Controller } from "stimulus"

export default class extends Controller {
static targets = [ "submit" ]
static values = { action: String }

// debouncing omitted for brevity
checkAvailable () {
const { action } = this.element
this.element.action = this.actionValue
// alternative to requestSubmit()
this.searchTarget.click()
this.element.action = action
}
}

The same Turbo stream response as per the form submission example above applies, but the user does not have to manually submit the form for it to happen.

# Noninteractive updates

Time passes, and you've now built a community around your website. Not only are users engaging with you, they want to engage with each other too. They want to know what others are doing on the site. Let's delight them with a site that updates in real time without user interaction.

Turbo can respond to Streams sent over Server Sent Events or Websockets, in addition to [intercepted] navigation/form-submissions. This allows the server to send updates to all users non-interactively. Out of the box,Turbo can listen to a websocket connection with the <turbo-cable-stream-source> tag, but this is somewhat specific to Rails Turbo . More generally, we can wire up an arbitrary connection with Turbo.connectStreamSource. Since we already have it, we can do this using Stimulus.

<!-- live update element -->
<div
data-controller="sse"
data-sse-url-value="/sse-endpoint" >

<div id="live-updates">
Loading…
</div>
</div>
// sse_controller.js
import { Controller } from "stimulus"
import {
connectStreamSource,
disconnectStreamSource
} from "@hotwired/turbo"

export default class extends Controller {
static values = { url: String }

connect () {
this.source = new EventSource(this.urlValue)
connectStreamSource(this.source)
}

disconnect () {
disconnectStreamSource(this.source)
this.source.close()
}
}

Now, whenever a new event happens, <div id="live-updates"> is updated automatically by the Event Source in /sse-endpoint with Turbo Stream messages without user interaction.

# Complex behaviour

Suddenly, there's a brief moment of panic that you may need to rewrite your entire website, when…

You've been in production for a while now, and audit for opportunities to reduce network latency or increase interactivity for features that can be performed entirely client-side safely, eg, things like date-pickers , or interactive graphs . You discover that many of these are written in React! Instead of loading the entire React stack for a few rare components, Stimulus can lazily load them. This way, you focus on using React only for the parts that actually need it.

<!-- html -->
<div
data-controller="react"
data-react-component-value="MyComponent"
data-react-props-value="{}" >

Loading…
</div>
// react_controller.js
import { Controller } from "stimulus"
import { moduleName } from "./helpers"

export default class extends Controller {
static values = {
component: String,
props: Object
}

async connect () {
const [
React,
ReactDOM,
Component
] = await Promise.all([
"react",
"react-dom",
moduleName(this.componentValue)
].map(m => import(m)))

ReactDOM.render(
<Component {...this.propsValue} />,
this.element
)
}
}

It is very tempting to use this as a way to migrate to using React, so you put this as a note in your README for future developers that you're deliberately avoiding complexity by using it only as needed.

# Going native

Not long later, there's another brief moment of panic that you may need to rewrite your entire website, when…

Users are increasingly wanting a native experience, so that they can leverage native capabilities like biometric authentication, notifications, etc.. Fortunately, Turbo comes with Android  and iOS  adapters, so that it is possible to reuse most existing pages and components already built with WebView, and only modify the high fidelity ones that will benefit from native integration, such as navigation bars.

# Postamble

It's several years since your initial launch, several library/frameworks have come and gone, but you're unaffected. Your development team remains small, and can rapidly deliver new features as the codebase doesn't have pervasive complexity, only complexity relevant to the features needed.

Progressive enhancement equals progressive development equals progressive complexity. When building the core business functionality, the TSSTCPW principle uses native browser capabilities first, then progressively enhances the experience with Javascript. This ensures maximum browser support, and performance, but importantly keeps complexity low. Then delighters improve the user experience by updating the view interactively and non-interactively. Only when there is proven need for heavy-weight Javascript libraries, are they built and lazily loaded. Even native mobile capabilities are added as necessary, preferring WebView for most pages.