Auto saving forms in the browser

Web performance conversations are often geared towards how fast content can be delivered from server to client, but writing data back to the server can be important too. 4 July 2022

# Native first, then progressively enhanced

Forms are the primary mechanism in which users submit input back to the web server. It's been around since the early days of the web; users can submit data encoded in either application/x-www-form-urlencoded or multipart/form-data. These encodings are native to browsers, and thus works without Javascript. Despite its seemingly simple key-value representation, various conventions  have arose that allow representing nested objects and arrays within this encoding. Security patterns like Cross Site Request Forgery tokens are well understood, and simple to implement. Without client Javascript, server responses with flash  messages  can be used to complete the feedback loop, albeit with klunky POST-Redirect-GET cycles.

We use this as the starting point to enable auto-saving forms. Such forms could be used for example, to perform server-side validation, like whether a username is available, to perform server-side auto-complete of domain-specific words, or to perform auto-save operations. In the extreme without JS, the form gracefully degrades to a standard HTML form. Far too many implementations jump straight to using JSON or even GraphQL mutations, which eliminates any form of graceful degration.

In order to support both JS and no-JS submissions, the server needs to be able to distinguish the two. A common convention (made popular by jQuery), is to append the header X-Requested-With: XMLHttpRequest when JS is available. Without this header, the server can assume that the submission was made by a browser without JS. This snippet for ExpressJS  shows how the server can handle both types.

if (req.xhr) {
res.json({ message })
} else {
res.flash("info", message) // prepare for subsequent GET
res.redirect()
}

# Browser send order

In order to send frequent updates to the server, and yet not overwhelm it, sending data needs to be debounced. But even this is not sufficient. When sending multiple potentially overlapping requests, the browser knows that the most recent request is correct. So, it should always cancel older requests (eg, using AbortController ), so that it is never bothered with obsoleted callbacks when they return. This helps bugs showing flashes of incorrect return data.

Example: The browser can send submission A, cancel submission A, send B, cancel B, then send C. With the cancellations, the browser don't have to worry about accidentally showing the results of A and B. Further, this can help reduce the server load too, as any cancelled request that hasn't made it's way to the server is cancelled. Depending on how smart the server is, it can also abort any ongoing requests.

# Server receive order

However, when receiving multiple potentially overlapping requests, the server does not actually know the order in which requests was made, so it may actually save the wrong thing!

Example: The browser can send submission A, (then optionally cancel A), and send submission B, but server can receive B then A, out of order. User will expect B to be saved, but instead A got saved. This edge case is often solved by a subsequent read-only step to confirm the form submission.

# ACID guarantees

If your backend provides Atomicity, Consistency, Isolation, and Durability  guarantees, ie, it will always save the correct thing, and raise an exception if it is unable to do so, then we are done.

However, if the underlying APIs do not provide such guarantees, we run the risk of saving parts of one submission mixed in with parts of a different submission. This can happen for example, if you had APIs with separate read and write endpoints instead of an atomic write endpoint that also returns the updated value.

There are many ways to solve this, ideally by changing the underlying APIs to provide such guarantees. Alternately, clever use of counters and semaphores to create pessimistic locking can guarantee that the server only ever processes one request at a time, but comes at great performance and complexity cost. The next best thing we can do is to detect when it happens, and alert the user accordingly. Fingerprinting the request and comparing the fingerprint on completion of the response can provide us with such a mechanism. Despite not giving strong guarantees, this can be sufficient in many applications.

# Server-side all the things

By pushing all business logic into the server, we build more resilient frontends. Business critical validation is performed server-side, and never duplicated on the client. The division of responsibilities, is such that the client is only ever focussed on enhancing the user experience - in this case, by auto-submitting the form at appropriate intervals. Without any client JS, the site is still functional.