Design engineering · One interaction, followed all the way down
The most design-engineer thing I know is a password field
A password checklist that ticks its rules off as you type is a small, settled, well-researched piece of design. It's also a fascinating trip through front-end and back-end technologies, to make it actually work.
Type something into this. It is the entire subject of the post.
- At least 8 characters
- One uppercase letter
- One number
The evidenceThe research came first
This pattern is one of the better-evidenced things in interface design.
In 2009, Luke Wroblewski published Inline Validation in Web Forms on A List Apart, reporting a study that compared the standard approach (validate after submit) against inline validation (validate as you go). Same form, same fields, same people. The inline version produced:
- +22%successful completions
- −22%errors made
- −42%completion time
- +31%satisfaction rating
- −47%eye fixations
That last number is the one designers should care about most, and it is the one that gets quoted least. Fewer eye fixations means people were looking around the page less: less scanning for what went wrong, less hunting back and forth between the field and the error message. It is cognitive load, measured directly rather than inferred.
And the qualitative finding underneath all five: users did not just need to know that something was wrong. They needed to know what was wrong, while they could still do something about it.
The craftThen the design did something with it
Wroblewski showed that inline validation works. He did not say what it should look like, and that gap is where the design happened.
The version everyone copied came out of Mailchimp’s signup redesign around 2012. Read the phrase “validate inline” and the obvious implementation is to move the error nearer to the mistake: check on blur rather than on submit, put the red text under the field instead of at the top of the page. That is not what they built.
They printed the rules under the field before you type anything, so there is no mistake left to catch. You are not being corrected, you are reading instructions and following them.
Then they broke the rules apart. “Password not strong enough” is technically inline validation, and it is nearly useless: it tells you that you failed without telling you what to change. Four lines holding four independent states answer a more useful question, which is which one you are still missing.
The last piece is the smallest one. A row turns green and a checkmark appears. Nothing in the 2009 study asked for that. It measured completions, errors and eye movements, and had no opinion about whether feedback should be pleasant. But resolving each rule separately turns the list from a set of demands into something closer to a progress bar, and that is most of why people remember this particular component.
None of it is complicated, and it is roughly as far as a design case study usually goes. All of it depends on the checking happening on every keystroke, which is where the difficulty starts.
Requirement oneIt has to happen as you type
Not on submit, not on blur. Every keystroke. That is the entire finding.
So something has to run on each keypress that does three things in order: read the current value, evaluate it against the rules, and update the interface to match. Those are three distinct jobs and only the second one is validation. The other two are the hard part.
The direct version is the code running in that demo above:
input.addEventListener("input", () => {
const v = input.value;
lengthRule.classList.toggle("ok", v.length >= 8);
upperRule.classList.toggle("ok", /[A-Z]/.test(v));
digitRule.classList.toggle("ok", /[0-9]/.test(v));
});
Fifteen lines, and around 2010 it would have been written in jQuery. At this size it is genuinely fine.
The problem is not this form. The problem is that every interface is this form. What accumulates is an ever-growing pile of code whose only job is “something changed, now go find the DOM nodes affected by it and fix them by hand”. That code does not compose, it grows faster than the features do, and every bug in it looks like a UI subtly out of sync with reality.
Which is the pain that produced the reactive frontend frameworks. Knockout, Angular, then React, all selling the same inversion: stop describing the edits, describe the result. Say what the UI should look like for a given state, and let the machine work out the minimal set of DOM operations to get there. React’s virtual DOM is precisely that, a diffing engine that turns “here is the new state” into “here are the four attributes that actually need to change”.
This checklist is close to the smallest complete example of the problem those frameworks exist to solve. Three derived booleans, three DOM nodes, keep them in sync forever. The shape of that solution matters later: state in one place, a diff computed against it, minimal patches applied to the DOM. It reappears further down the stack, and that repetition is the most interesting thing in the post.
Requirement one is satisfied, and it dragged in the entire history of frontend architecture on the way.
Requirement twoIt has to happen on the server
This one is not negotiable, and it is not a preference.
A form is not a gate. It is a suggestion. Anyone can open a terminal and POST directly to the endpoint with a one-character password, and nothing in the browser is involved in that transaction. The rules must be enforced where the data actually lands, which means in the backend, in the changeset or the model or the handler, on every request, no exceptions.
The clearest way to put it: the client-side check is not validation, it is feedback. The server-side check is not feedback, it is validation.
They look identical, they contain the same regexes, and they do completely different jobs. One shapes an interaction. The other is the only thing standing between a database and the open internet.
Which is why “just write it once” is harder than it sounds. The two copies are not redundant. They are in different places for different reasons, and both reasons are real.
The collisionTwo requirements, opposite directions
Put them next to each other and the contradiction is right there:
Requirement one
Real-time feedback needs the rules where the typing happens. That is the browser.Requirement two
Security needs the rules where the data lands. That is the server.Two different machines with a network between them. The rules now exist twice, in two languages, maintained by whoever remembers.
And the naive version looks easy. A regex here and a regex there, ten minutes of work, ship it. The cost arrives six months later, when someone raises the minimum length to ten characters and updates one of the two places. Now either the UI rejects passwords the backend would accept, which is a bug people complain about, or the UI accepts passwords the backend rejects, which is a worse bug, or the reverse of one of those, which is a security hole nobody notices for a year.
This is drift, and it is the actual engineering problem underneath a green checkmark. Everything in the rest of this post is an attempt to solve it. Here is where that goes, before we go there:
Step 1Move the rules to the server and stream the keystrokes
The most direct answer: delete the browser copy. There is one set of rules, on the server, and the keystrokes are sent to it.
This is what Phoenix LiveView was built for, specifically. When Chris McCord announced it at ElixirConf 2018, live form validation was one of the opening demos. The rules live in exactly one place:
def registration_changeset(user, attrs) do
user
|> cast(attrs, [:password])
|> validate_length(:password, min: 8)
|> validate_format(:password, ~r/[A-Z]/, message: "needs an uppercase letter")
|> validate_format(:password, ~r/[0-9]/, message: "needs a number")
end
You bind the form with phx-change, keystrokes go to the server, the server runs that function and
pushes back what changed, the browser patches it in. No JavaScript in the loop, and no second copy to drift,
because the thing the user sees and the thing the database enforces are the same code.
It was influential enough that most ecosystems built a version: Livewire for Laravel, StimulusReflex for Rails, Blazor Server for .NET, jfyne/live for Go. They do not work the same way underneath, and that turns out to be the whole story.
Because a request per keystroke sounds absurd.
Step 2What does a keystroke actually cost?
It depends entirely on what “a request” means, and the gap between the options is larger than it looks.
Over an already-open WebSocket, a keystroke is a few bytes of framing plus the field contents. And the response is not HTML: LiveView’s template compiler splits the markup into static and dynamic parts at compile time, so when one rule flips, what comes back is a small JSON structure saying which dynamic slot changed and to what. The client already holds the statics. It patches one attribute.
Look at what that is. State in one place, a diff computed against the previous state, minimal patches applied to the DOM. That is React’s virtual DOM from requirement one, with the diffing moved from the browser tab to a server process. The framing at the time was roughly “this is React, we just moved the state management to the server”, and as a description it is accurate. The same idea, at a different layer, solving the duplication problem instead of the synchronisation problem.
Over plain HTTP, the same keystroke costs a full request: headers and cookies (commonly 1 to 2kB before any payload), possibly a TLS negotiation, a trip through the middleware stack, a complete server-side template render, and a chunk of HTML coming back to be swapped into the DOM.
Same interaction, roughly an order of magnitude apart in both bytes and server work.
So the socket version is clearly better. Why did most ecosystems not do that?
Step 3Why doesn’t everyone hold a socket open?
Because the socket is not the cost. What sits behind it is the cost.
Validating on the server means remembering, per user, what their form currently contains. That means the server holds something stateful for every connected person, and what that costs is not a framework decision. It is set by the runtime underneath. This is the first step below anything a frontend developer normally has to think about:
- PHP cannot do it at all in the traditional model, because the process dies when the response is sent. Livewire’s workaround is to encrypt the component state, hide it in the DOM, and ship it back and forth with every AJAX request. It feels like LiveView to write. Underneath it is very well-organised AJAX.
- Ruby can hold connections through ActionCable, which is what StimulusReflex uses, but each one costs a thread, and threads are heavy enough that you feel it. Hotwire, the mainstream Rails answer, mostly sidesteps this by staying on stateless HTTP.
- The BEAM (Erlang, Elixir) allocates a process per connection: a few kB, isolated, individually garbage collected, preemptively scheduled across every core. Holding a hundred thousand is routine, which is why LiveView could make this trade at all.
- Go lands in the same category by a different route. Goroutines are cheap and the runtime multiplexes them across cores, so Go can genuinely hold the connections too.
- Node is the one that gets misrepresented. A common claim is that Node “cannot” hold many WebSockets and would “instantly crash”. That is wrong. Node holds tens of thousands of sockets fine, and plenty of production systems prove it nightly. The real difference is isolation. On the BEAM, one user’s slow render cannot stall anybody else’s, because each is separately scheduled. On a single-threaded event loop that is not free, and it has to be earned back with clustering and care.
Notice what has happened to the question. It began as “when should the checkmark turn green” and it is now “what does this language’s runtime charge for a concurrent unit of work”. Those are the same question.
It also cuts the other way.
Step 4Doesn’t the stateless version choke the server too?
Yes. It fails differently, and this is where the trade stops being abstract.
An idle socket costs memory, sitting there whether the user is typing or not. A POST per keystroke costs CPU: TLS, header and cookie parsing, middleware, template rendering, serialisation, repeatedly, in bursts, five to ten times a second per user. The first shows up as a memory graph creeping upward. The second melts a core.
Which is why the stateless version never ships as written. Here is the htmx form, and there is one attribute worth staring at:
<input
type="password"
name="password"
hx-post="/validate-password"
hx-trigger="keyup changed delay:500ms"
hx-target="#password-checklist" />
That delay:500ms is a debounce. It exists so the server survives.
A debounce is not a design decision. It is a protocol tax that surfaced in the UI, and the bill is paid by the user, who types their capital letter and waits half a second to watch the row go green. Half a second is roughly the sluggishness this entire pattern was invented to remove. A post-submit error message was designed away, and most of its latency reintroduced at the input.
That number, sitting in an HTML attribute, is a direct readout of the backend’s concurrency model. It is the clearest case of an infrastructure constraint surfacing as a visible design flaw.
So what does the largest ecosystem do about it?
Step 5What does the JavaScript world do?
Next.js Server Actions are HTTP POSTs with better syntax, so they inherit all of the above, plus one extra problem: React queues them sequentially on the client to prevent race conditions. Fire one per keystroke and they line up behind each other, each waiting for the previous round trip. That is a bad way to run a checklist.
So the React answer is to not do it on the server at all. Run the rules in the browser for instant feedback, and re-run them on the server at submit, because the client is still not trustworthy.
Which is exactly the split from requirement one and requirement two, accepted rather than resolved. And worth saying plainly: this is what the sites famous for this pattern actually do. Mailchimp’s frontend is React, and it is not sending keystrokes anywhere to find out whether someone typed a capital letter. Neither are Stripe, GitHub, or Apple ID. The pattern everyone admires has been client-side the whole time.
Which sounds like the duplication we started with.
Step 6Isn’t that just the duplication again?
No, and the difference is the entire point.
export const passwordSchema = z
.string()
.min(8, "At least 8 characters")
.regex(/[A-Z]/, "One uppercase letter");
One module, imported in two places. Change the eight to a ten and both sides move together. The rules exist once in the source and twice at runtime, and the two runtime copies cannot disagree, because they are the same file. Drift, the thing that made this hard, is gone.
The industry calls this isomorphic validation, and the reason it works is structural rather than clever: JavaScript happens to run in both environments. It is a coincidence of history that the browser’s language is also a server language, and that coincidence is worth an enormous amount.
But it means shipping a validation engine to every visitor. What does that cost?
Step 7How big is that, really?
Small enough that the question dissolves. Zod v4 is around 5kB gzipped, and zod/mini is under
2kB. Roughly one small icon.
Then the comparison gets funny. Recall from step 2 that a single HTTP request’s headers and cookies commonly run 1 to 2kB. So validating server-side over HTTP, across a ten-character password, spends several times more on the wire than shipping the entire validation library to the browser once and caching it from then on.
On bytes, the client-side version does not just tie. It wins outright, and it is the one usually described as the compromise.
So the real cost is not bandwidth. It is that the rules now have to be expressible in whatever language the frontend speaks. For a TypeScript team that is not a price at all. For an Elixir, Go, Rust, or Python team it is the entire conversation, and it is why the server-side frameworks in step 1 exist in the first place.
Which raises the obvious question: could you have both?
Step 8What if the socket only opened while the form was on screen?
A password form is a rounding error in a user’s lifetime with a product. If a hundred thousand people are logged in and fifty are on the signup page, fifty sockets is nothing. Open it on mount, close it on unmount, keep the rules on the server, skip the debounce.
Three things kill it, and all three live below the frontend:
- The handshake. A WebSocket starts as an HTTP request that gets upgraded, costing 50 to 200ms depending on physical distance to the server. Focus the field, start typing quickly, and the first characters land while the connection is still being established. The exact moment being optimised for is the one moment the socket is not ready. A schema that shipped with the bundle is already warm.
- Signup pages are where spikes land. The “only fifty at a time” assumption inverts precisely when it matters. Get posted somewhere large and nearly all incoming traffic arrives at that one form at once, while the server is already busy rendering the page it sits on.
- A connection lifecycle to own. Reconnects, mid-form network drops, unmount cleanup, out-of-sync states. Real code with real bugs, in service of a checkmark.
But the idea is not wrong, it is aimed at the wrong rule. It becomes exactly right the moment the rule needs something the browser cannot have:
- Is this username still available?
- Has this password appeared in a breach corpus, checked against a service whose API key must never reach the client?
- What does this quote cost, under pricing logic that is not shipping to competitors?
Which produces a much better dividing line than “client-side versus server-side”:
If the rule is a regex, ship it. If the rule is a query, keep it.
“Eight characters, one uppercase” is a regex. No secrets, no state, no dependencies. It falls on the ship-it side, cleanly, and the entire ecosystem argument turns out not to apply to the case that started it.
That settles the design question. The engineering underneath it has two more floors.
Step 9What does the Go version look like?
Stateless first. A handler that renders the checklist fragment:
http.HandleFunc("/validate-password", func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
password := r.FormValue("password")
tmpl.Execute(w, ValidationState{
LengthValid: len(password) >= 8,
UpperValid: strings.ContainsAny(password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ"),
})
})
htmx does have a WebSocket extension, so the debounce can go and the rules can stay on the server:
<div hx-ext="ws" ws-connect="/ws">
<input type="password" name="password" hx-trigger="keyup changed" ws-send />
<div id="password-checklist">...</div>
</div>
Two honest caveats. This sends whole HTML fragments over the socket rather than structural diffs, so it gets the connection savings but not the payload savings from step 2. And it means hand-writing the socket handling a framework would otherwise own:
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, _ := upgrader.Upgrade(w, r, nil)
defer conn.Close()
for {
_, message, err := conn.ReadMessage() // blocks here
if err != nil {
break
}
// parse JSON, validate, render fragment, write back
}
}
That loop contains the last question, and the bottom of the whole thing.
Step 10Is that using goroutines?
Yes, and it is invisible, which is exactly why it is worth pointing at.
There is no go keyword anywhere in that code. Go’s net/http spawns a goroutine
per incoming connection automatically. So that for { conn.ReadMessage() } loop, which sits and
blocks indefinitely waiting for the next keystroke, is running inside its own scheduled unit of work.
In a single-threaded model, a blocking loop like that is catastrophic. It freezes everything: nobody else loads a page while that one user thinks about their password. In Go it is fine, and the reasons are unambiguously machine-level:
- The goroutine starts with a stack of about 2kB, growing only if needed. An idle connection is cheap in the literal, count-the-bytes sense.
- Blocking on a network read does not block an OS thread. The runtime parks the goroutine and hands the thread to somebody else.
- The scheduler multiplexes all of them across every available core, and preempts any goroutine that runs too long, so none of them can starve the rest.
That is structurally the same trick the BEAM plays with processes, arrived at independently, and it is why Go and Elixir keep landing in the same column while Node, Ruby, and PHP land in the others. Not because of syntax or ecosystem, but because of how the runtime schedules and how much an idle waiting thing weighs.
And that is the floor. A chain that started at “should this checkmark appear now or in half a second” ends at stack sizes, scheduler preemption, core counts, TLS negotiation cost, and the speed of light between a user and a data centre. Nothing along the way left the original topic.
The other direction: what if the compiler solved it?
One branch worth naming, because it attacks the collision from the opposite end. Hologram compiles Elixir into JavaScript: a component’s actions run in the browser at 0ms, its commands run on the server, one language, no ecosystem boundary to share a schema across.
The duplication does not vanish, it moves into the compiler. The machine maintains two executable versions of the logic, paid for with an Elixir runtime in the browser bundle, reimplementing enough of the standard library to run the code, plus an interop wall the first time an npm chart library is needed.
An interesting bet, and overkill for validating a password. It is here because it is the only answer in the set that tries to make the language boundary from step 7 disappear rather than live with it.
The lineageHow we actually got here
Everything above is a snapshot of six answers that exist right now. This section is how they came to exist, in order, with the pressure that caused each one, because the version of this history that circulates casually is wrong in three specific ways.
The one thing to track: every framework in this post is an answer to a single question. Where does the application’s state live, and who works out what the screen should look like? Follow that one variable and the history stops being a list of names and becomes a single argument with a shape.
Era 1: the server held everything
Before roughly 2005, the answer was not in dispute. State lived on the server. Submit a form, the server processes it, renders a fresh page, sends the whole thing back. The browser displays documents.
Note what that means for our problem: the drift problem did not exist. The rules lived in one place because there was nowhere else to put them. There was no browser copy to disagree with the server copy, and no way to give feedback before submit either, because feedback required a round trip and a full page reload.
The problem in this post was not discovered. It was created, deliberately, in exchange for something worth having.
Era 2: AJAX cracks it open (2005 onward)
XMLHttpRequest made it possible to talk to the server without reloading the page, Gmail and
Google Maps proved it was worth doing, and the term AJAX was coined in 2005. jQuery arrived in 2006 and made
DOM manipulation bearable across browsers, which mattered enormously at the time.
The consequence is the important part. For the first time it was worth putting real behaviour in the browser, so logic started migrating there. This is where the second copy of the rules is born, and it is also the exact moment inline validation becomes possible at all.
Which is why the research at the top of this post is dated 2009. Wroblewski was not studying a timeless design principle. He was studying what AJAX had just made buildable, about four years after it became practical and right as the industry started doing it badly.
Era 3: two answers to the same mess (2007 to 2013)
The era 2 approach does not scale. Hand-written “something changed, go patch the right DOM nodes” code grows faster than the app does, which is the wall described in requirement one. Two traditions formed around fixing it, and they are still the two columns of every comparison table in this post.
The server answer: ASP.NET AJAX UpdatePanel, 2007. Keep the state on the server, let the server re-render a region, ship the update down, swap it in. Widely mocked at the time, mostly deservedly, because of ViewState bloat and how opaque it was. But hold onto it: everything in the right-hand column for the next fifteen years is this idea done properly.
The client answer: Knockout, Backbone, and AngularJS, all around 2010. Bind the DOM to data declaratively so nobody writes the patch code by hand. Then React, 2013, which made the decisive move: describe the UI as a pure function of state, keep a virtual copy of the tree, and let a diffing engine compute the minimum set of real DOM operations.
React won because it solved the synchronisation problem rather than the binding problem. Stop saying what to change, start saying what the result should be. That shape comes back shortly, wearing a different hat.
Era 4: the SPA era, and its bill (2013 to 2016)
Follow React’s premise to its conclusion and the result is the SPA. If the browser holds the state and renders the UI, the server stops rendering anything and becomes a JSON API.
That bought a great deal: app-like navigation with no page flashes, offline capability, one API serving web and mobile clients, and a clean seam for splitting a company into frontend and backend teams. Those wins are why SPAs took over, and none of them have anything to do with validation.
The bill arrived as three separate charges:
- Duplicated logic. Two applications in two languages sharing one contract. Drift stops being an accident and becomes structural.
- Bundle size. The browser now downloads the application before it can show anything.
- Empty first paint and no SEO. The page is a blank div until JavaScript runs.
Next.js, October 2016, is the answer to charges 2 and 3. Server-side rendering so the first response contains real HTML, plus PHP-style file-based routing because that ergonomics was genuinely better. It is not an answer to charge 1, and this is the most important date in this section: Next.js is two years older than LiveView. Whatever else is true, it cannot have been imitating it.
Era 5: the other tradition never actually left (2012 to 2018)
While all that happened, the server-state tradition kept going, and its steps stack neatly:
- Turbolinks, 2012. Keep rendering HTML on the server, but intercept navigation and swap the body instead of reloading. SPA-feeling navigation with none of the SPA architecture.
- Meteor, 2012. Full-stack reactivity over a persistent connection, years early, and a useful lesson in how much runtime support the idea needs.
- Phoenix Channels, 2015. Not a UI framework. Infrastructure: persistent connections cheap enough to hold in enormous numbers, because the BEAM gives you a supervised, isolated, individually scheduled process per connection.
- Drab, 2016. An Elixir library for manipulating the browser DOM directly from server code over a channel. This is the real prior art for LiveView inside its own ecosystem, and it is almost never mentioned.
- LiveView, September 2018.
LiveView needed two ingredients, and they are worth separating because usually only the first gets credited:
- A stateful process per connection, which the BEAM makes affordable. That solves where the state lives.
- A compile-time split of templates into static and dynamic parts, so the server can send a tiny structure describing which dynamic slot changed rather than a blob of HTML. That solves what it costs to keep the browser in sync.
Ingredient 2 is React’s virtual DOM insight from era 3, relocated. State in one place, diff computed against it, minimal patches applied. Same shape, running on a server, solving the duplication problem instead of the synchronisation problem. That is the rhyme this whole post is built on.
Two frameworks genuinely followed it: StimulusReflex in October 2018, one month after the keynote, and Livewire in February 2019. Two more arrived at similar places independently: Blazor Server, 2019, which comes down the WebForms and SignalR line rather than the Phoenix one, and Hotwire, December 2020, which is Turbolinks continuing on its own track and is not a LiveView clone whatever the surface resemblance.
Era 6: the client tradition walks it back (2019 to now)
- Hooks, February 2019. Worth being precise: this is ergonomics on the 2013 idea, not a new idea, and it lands five months after LiveView. Any version of this story that runs “useState, therefore LiveView” has the dates backwards.
- React Server Components, announced December 2020, and Server Actions, stable in Next.js 14 in October 2023. These do move work back to the server, in the same direction LiveView went. But the cause is different: bundle size, data fetching, and caching, not validation drift. And the machinery is different: stateless HTTP requests and streamed payloads, with no persistent connection and no stateful process. Convergent evolution, not influence.
- Zod, March 2020. The answer to charge 1 that is not a framework at all. Write the rules once as a schema, import that module on both sides. It works because JavaScript runs in both places, and it works well because TypeScript lets a single declaration be both the runtime validator and the static type. A library solved what everyone assumed needed an architecture.
- Hologram, 2020. Not the Next.js pattern on the BEAM. It is the SPA pattern on the BEAM: state in the browser, written in Elixir, with the server reached explicitly when needed. By this taxonomy it sits in the client column, arriving from the server side.
The tidy version, and why it is wrong
The story that circulates goes: React invents component state, LiveView moves that state to the server because the BEAM makes it cheap, Next.js copies LiveView, discovers its runtime cannot hold the connections, and retreats to the client, which is where Zod comes in.
Three things do not survive the dates:
- Hooks are not the starting point. February 2019, five months after LiveView. The React idea that matters is from 2013.
- Next.js could not have been copying LiveView. October 2016, two years earlier, and built to solve first paint and SEO.
- Nobody retreated from WebSockets. Next.js never tried holding sockets for this and gave up. RSC was stateless HTTP from the first commit. The trade-off in steps 3 and 4 is real as engineering. It is not a thing that happened as history.
There is a smaller one that causes most of the confusion. McCord’s line about LiveView being “React, we just moved the state management to the server” is borrowed vocabulary, used to explain an unfamiliar thing in familiar terms. The architecture came from Phoenix Channels, the BEAM, and Drab. React supplied the explanation, not the design.
The actual shape
The left column spent a decade moving state into the browser and then started walking it back. The right column never moved it in the first place, and got a lot better at pushing updates out. They meet around 2020 to 2023, having independently concluded that the server should be doing more, and they meet holding completely different tools, for the reason this whole post is about: what a runtime can afford to hold.
What to take from that
- State moved server, then client, then partway back. Everything else is detail.
- The second copy of the rules is not an accident. It is the bill for era 2, and every approach in this post is a way of paying it.
- LiveView needed two ingredients, not one. Cheap stateful connections and compile-time template diffing. Ecosystems that copied only the first got a heavier framework and a worse payload.
- Next.js and LiveView are convergent, not causal. Same destination, opposite starting points, different machinery, twenty-six months apart in the wrong direction for imitation.
- Which answer is affordable is set by the runtime, not by taste. That is the sentence the entire post exists to support.
What this does and does not explain
It would be nice to claim that one password field explains the entire history of frontend architecture. It does not, and the overreach is worth resisting.
What it genuinely explains: why declarative frontends beat patching the DOM by hand, why the trust boundary forces the logic to exist twice, why the server-state frameworks exist at all, and why runtime capability rather than taste decides which answer a given ecosystem can afford.
What it does not explain: why SPAs won in the first place, which was routing, app-like navigation, mobile clients, and organisations splitting into frontend and backend teams around an API contract. Or why Next.js became popular, which was SEO, first paint, file-based routing ergonomics, and a deployment story. Or what actually drove React Server Components, which was data fetching, caching, and bundle size. A password field has none of those pressures in it.
The narrower claim is the stronger one: this interaction isolates the single axis all of these frameworks genuinely disagree about, which is where the state and the logic live, and what the network charges to move them. That axis does run through all of it.
The map
| Approach | Rules run | Carried by | First feedback | What you pay |
|---|---|---|---|---|
| Client-side schema React + Zod, any SPA |
Both, one module | Nothing while typing, one POST at submit | 0ms | Rules must be expressible in the frontend’s language |
| Phoenix LiveView | Server only | Persistent socket, structural diffs | One round trip | A live process per connected user, cheap on the BEAM, not everywhere |
| Blazor Server, StimulusReflex | Server only | Persistent socket, diffs | One round trip | Same trade, heavier runtime, wants sticky sessions and a backplane |
| Livewire, Hotwire | Server only | HTTP per input, HTML back | Debounce plus round trip, 500ms+ | The debounce is visible in the UI |
| htmx + Go over sockets | Server only | Persistent socket, HTML fragments | One round trip | Hand-writing the socket loop |
| Hologram | Both, one source | A compiler | 0ms | Elixir runtime in the bundle, interop wall at the edges |
Six answers. Same checkmark.
How far down that went
Laid out as layers, one interaction touched all of these:
- User research. Does inline feedback measurably help, and by how much.
- Information architecture. Show the rules before they are broken, split into independent parts.
- Visual and interaction design. Make the state change read as progress rather than judgement.
- Frontend engineering. Event handling, derived state, keeping the DOM in sync, and the framework lineage that grew out of doing it by hand.
- Trust boundaries. What the client is permitted to be believed about, which is nothing.
- Network protocol. HTTP versus WebSocket, header overhead, handshake cost, round trips.
- Backend architecture. Where the rules live, template compilation, static and dynamic diffing.
- Language runtime. Processes, goroutines, threads, event loops, and what each one charges.
- Scheduler and hardware. Preemption, cores, kilobytes per idle connection.
- Physics. The distance between a user and a server, which nobody optimises away.
The direction of causality is the interesting part. It runs upward. What the scheduler can do sets what the runtime can hold, which sets what the framework can offer, which sets whether a debounce is needed, which sets whether the checkmark feels instant, which sets whether you get Wroblewski’s 22%. Layer 9 decides layer 1. The user never knows any of it and feels all of it.
What design engineer means
This is what I mean by the term, and why I find it useful rather than fashionable.
Nothing in that chain was a question outside the original one. Should the checkmark go green now, or in half a second? Every answer to it turns out to be somebody’s concurrency model.
The checkmark can be designed without knowing any of this, and the design will be correct. What cannot be done without it is saying whether it is achievable at 0ms on this stack, what it will cost in six months, or which of those six approaches a team will still be maintaining in two years. That judgement does not live in the design tool, and it does not live in the backend. It lives in the space between, which is narrow and surprisingly deep.
And the deflating half, which is the honest ending: the breadth does not produce an exotic answer here. It produces the boring one. For this specific rule, put the schema in a shared module, run it in the browser for the checkmarks, run it again on the server because you must, and save the socket for rules that genuinely need the server.
Which is what the breadth is for. Not to find the clever answer. To recognise when the boring one is correct, and to be able to say exactly why, in terms of the thing that actually constrains it.
Links
- Inline Validation in Web Forms, Luke Wroblewski, A List Apart, 2009. The original research and the five numbers.
- ElixirConf 2018 keynote, Chris McCord, where LiveView and this exact demo were announced.
- chrismccord/phoenix_live_view_example, the source from that talk.
- Drab, the 2016 Elixir library that drove the DOM from the server before LiveView.
- htmx WebSocket extension, the socket without the framework.
- jfyne/live, LiveView ported to Go.
- Hologram, Elixir compiled to JavaScript.