Design engineering · One interaction, followed all the way down
The "design engineer" role goes beyond front-end
The industry currently narrows down a design engineer to a "designer who ships AI-generated front-end". Let's aim higher than that, and let me take you on a fascinating trip behind a simple password input.
Type something into this. It is the main subject of the post.
- 8 characters minimum
- One uppercase character
- One number
The evidenceWhere did this come from
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 then-standard approach (validate form 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
The study had 22 participants and six variations of the form; these are the reported differences between the best-performing inline version and the control.
These numbers are massive. That's the kind of difference that can turn into real BIG MONEY—and as designers, we should never forget we're designing for impact. But this was a small usability study, not a production A/B test, so it would be a mistake to read it as “inline validation increases signups by 22%” everywhere.
That last number is especially interesting for designers. Fewer eye fixations mean less confused jumping around the page, figuring out what went wrong. It's a useful proxy for visual effort, not a direct measurement of cognitive load.
And the qualitative finding: 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 study also found that timing matters: showing errors before somebody has finished can be worse than waiting. The positive checklist here is a slightly different interaction—it shows progress as rules become true instead of declaring the unfinished value wrong.
Note: the research is from 2009, yet you still find plenty of sites that do just post-submit validation in 2026. And if they wipe your form on error, that's the tableflip moment.
The craftTaking the design to another level
Mailchimp redesigned their signup around 2012. They printed all the individual rules under the field, and checked them off as you type. You are not being corrected, you are reading instructions and following them.
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.
Up until here it's the "design" portion of the "design engineer" job. A fantastic design case study, where quality research found a goldmine, and was further refined to a delightful experience (well, turning an annoying step into an almost pleasant one).
A note before we build: these password rules are here because they make a beautifully visible demo, not because they are current password-policy advice. Modern guidance leans toward length, blocklists and password-manager support instead of forced uppercase-and-number composition rules. And many products now avoid password creation altogether with magic links, social login or passkeys.
Now comes the "engineer" part. Because it turns out it's not so easy. And it ultimately takes us on a fascinating journey through web technologies, and even deeper.
Requirement oneIt has to happen as you type
Not on submit, not on blur. Every keystroke.
So something has to run on each keypress that does three things in order:
- Read the current value.
- Evaluate it against the rules.
- Update the interface to match.
Those are three distinct jobs and only the second one is validation. The other two are the hard part.
Back in 2009 you would already be reaching for jQuery, the library that had become the default way to find elements and manipulate the DOM without minding which browser you were in.
$("#password").keyup(function () {
var value = $(this).val();
$("#rule-length").toggleClass("ok", value.length >= 8);
$("#rule-upper").toggleClass("ok", /[A-Z]/.test(value));
$("#rule-number").toggleClass("ok", /[0-9]/.test(value));
});
This looks fine, thanks to jQuery. The problem with the approach soon emerged: 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. In other words, it gets messy and hard to maintain real quick.
This new pain produced the reactive frontend frameworks. Angular, then React, both 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.
function PasswordField() {
const [value, setValue] = useState("");
return (
<>
<input value={value} onChange={(e) => setValue(e.target.value)} />
<ul>
<li className={value.length >= 8 ? "ok" : ""}>8 characters minimum</li>
<li className={/[A-Z]/.test(value) ? "ok" : ""}>One uppercase character</li>
<li className={/[0-9]/.test(value) ? "ok" : ""}>One number</li>
</ul>
</>
);
}
Nothing in there tells the DOM to change. There is no toggleClass, no classList, no
instruction to go and update anything. You describe what the checklist is for a given value, and
React’s virtual DOM works out the difference: a diffing engine that turns “here is the new state” into
“here are the 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. But that's just front-end...
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. And the naive solution looks easy. Same regular expression on both sides, 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. The two copies now disagree, and which way they disagree decides how much it costs you.
Update the server and forget the browser, and the checklist turns green on eight characters before the submit is rejected. Confusing, visible, reported within a day.
Update the browser and forget the server, and nothing looks wrong at all. Every user sees the new rule and obeys it. Anyone who skips the form gets the old one, because the only thing actually enforcing a password policy is the server. You believe you require ten characters. You require eight, and nothing on screen will ever tell you otherwise.
The browser pathStart where almost everyone starts
Requirement one is easy in the browser. No network, no server, effectively immediate. The jQuery and React versions above are already the whole thing.
And that’s what the famous examples generally do. A capital-letter or length rule needs no server answer, so this kind of instant checklist naturally lives in the browser.
But requirement two doesn’t go away. The server still has to enforce the same rules, so they exist twice. Everything below is a different way of paying for that.
Trade oneOne rule module, load on both sides
The obvious fix is to stop writing them twice. Write a single module and import it on both sides.
export const passwordSchema = z
.string()
.min(8, "8 characters minimum")
.regex(/[A-Z]/, "One uppercase character")
.regex(/[0-9]/, "One number");
Change the eight to a ten and both sides move together. The rules exist once in the source, twice at runtime, and the two runtime copies can’t disagree because they’re the same file. Drift solved.
It’s cheap, too. Zod v4 is roughly 6kB gzipped, zod/mini roughly 2kB. That small one-time download
buys immediate local feedback without a request on every keystroke. The server still validates the
final value; sharing the module simply keeps those two jobs based on the same rules.
This works because JavaScript runs in both places, and that isn’t luck. People had been pushing JS onto the server since Netscape shipped it in 1996, precisely so both ends could share code. Node is the attempt that stuck. Obviously this doesn’t work out of the box if your frontend and backend languages differ.
Trade twoServer roundtrip, where inevitable
Some rules just need the server, no way around it. The classic one is the field right above the password.
Is this username taken?
I've used the username example for simplicity, but it's the same shape of problem as checking a reused password, checking whether a password appeared in a known breach, etc.: the answer lives in a database. So the browser has to ask for it, wait, and avoid hammering the endpoint:
"use client";
export function UsernameField() {
const [username, setUsername] = useState("");
const [available, setAvailable] = useState<boolean | null>(null);
useEffect(() => {
setAvailable(null);
const timer = setTimeout(async () => {
const res = await fetch(`/api/username?username=${username}`);
setAvailable((await res.json()).available);
}, 300);
return () => clearTimeout(timer); // they kept typing, drop it
}, [username]);
return <input value={username} onChange={(e) => setUsername(e.target.value)} />;
}
The useEffect runs after every keystroke, but it doesn’t fetch anything. It schedules a
fetch for 300ms later and hands back a cleanup function.
That cleanup is the whole trick. React runs it right before the effect runs again, so the next keystroke cancels the timer the previous one set. Type five letters quickly and you schedule five requests and cancel four. Only the last one survives its 300ms, and it fires once you stop typing.
That’s called debouncing, and it exists so the server doesn’t get hit on every keystroke. It also runs on unmount, so leaving the page doesn’t fire a request at a component that isn’t there any more. But if you type slowly, it will fire after every keystroke.
This is the simplified version. Once a timer has fired, clearTimeout cannot cancel the request
already in flight. Real code also needs an AbortController (or a request ID) so an older response
cannot arrive late and overwrite a newer one.
Trade threeCan't we use a WebSocket instead?
If HTTP per keystroke is the expensive part, can't we open and keep a connection instead? Every keystroke travels over an already-open channel, and the response comes back the same way. Yes, we can. Node can too. But a persistent connection changes the cost model:
Three things get in the way, and they all sit below the frontend:
- You’re holding a connection for everyone. Signup pages take more bot, scraper and drive-by traffic than any other page you own. Open the socket on page load and you’re carrying a stateful connection for all of them, and almost none will create an account.
- You own the lifecycle. Reconnects, dropped mobile signal, unmount cleanup, out-of-sync states. Real bugs, in service of a checkmark.
- The socket doesn’t make server work free. Node handles large numbers of connections well,
especially while their work is asynchronous I/O. But CPU-heavy synchronous work such as password scoring
or
bcrypt.hashSyncblocks the event loop of the process running it. Production systems move that work to worker threads or other processes, and spread sockets across multiple processes or machines. That is perfectly workable, but it is architecture you now own.
Opening it on page load does fix one thing. The handshake needs its own network round trip, and doing it that early means the socket is warm long before anyone reaches the password field. Wait until the field gets focus and the first characters land while it’s still connecting.
None of this disqualifies WebSockets, or Node. It explains why the previous solution is such a solid compromise: keep the cheap feedback local, debounce the checks that truly need a server, and validate again on submit. For length and character classes, that is usually the right answer.
What’s interesting is what the alternatives trade instead.
The server-only pathA different frontend architecture
Phoenix LiveView establishes a WebSocket and keeps the source of truth on the server. Actions travel up from the browser; the server updates its state, renders again, and UI diffs travel back. The persistent connection and server-held state are not an implementation detail. They are the frontend architecture.
LiveView is built on the BEAM, the system that Erlang and Elixir applications run on. It was designed to handle millions of small, independent jobs at once, which makes this viable and easy.
It gives every connected visitor a tiny, isolated process—almost like each person has a miniature server of their own. The computer is still shared, but the BEAM keeps switching between those processes, so one slow interaction does not leave everybody else waiting, and one crashing view does not take the others with it.
Node can serve huge numbers too, but its JavaScript normally takes turns on a shared event loop: one piece of synchronous work that takes too long holds up the queue. Production Node systems solve that with workers and multiple processes. On the BEAM, that isolation is the starting point.
When Chris McCord announced it at ElixirConf 2018, live form validation was one of the opening demos, in fact. And this is a stripped-down LiveView module that mounts the view, renders the HTML form and runs the validation.
One note before the code: Elixir can look alien at first if JavaScript is your main language. Developers coming from Ruby or Python often find it much easier to grasp. The bigger adjustment is the shift toward functional programming, but that’s a different story from the one we’re telling here.
defmodule DemoWeb.UserLive.New do
use DemoWeb, :live_view
def mount(_params, _session, socket) do
changeset = Accounts.change_user(%User{})
{:ok, assign(socket, form: to_form(changeset))}
end
def handle_event("validate", %{"user" => params}, socket) do
changeset = Accounts.change_user(%User{}, params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
def render(assigns) do
~H"""
<.form for={@form} phx-change="validate">
<.input field={@form[:password]} type="password" label="Password" />
</.form>
"""
end
end
That phx-change sends each change to the server, which runs that change_user validation function
and pushes back what changed. No application-specific validation JavaScript, no second copy to drift.
The server pathWhat a keystroke actually costs
The compact response coming back from the server is worth unpacking, because it is part of what makes streaming raw keystrokes viable rather than absurd.
You can watch this happen in an interactive demo that runs the same password field four ways and shows what happens in the browser, on the server and between them.
LiveView, over the open socket:
{"1": "ok"}
Purpose-built HTTP endpoint:
POST /validate-password
{"length": false, "uppercase": true, "number": false}
The sketch above is deliberately schematic, but the underlying trick is real. LiveView tracks the static and dynamic parts of the rendered template, then sends a compact diff rather than the whole page. A keystroke may change one dynamic slot or several; either way, unchanged markup does not need to travel again.
This is not a claim that HTTP must resend a whole page or open a fresh connection every time. HTTP/2 and HTTP/3 reuse connections and compress headers, and a purpose-built endpoint can return tiny JSON. LiveView’s advantage is not raw bytes against the smallest endpoint you could hand-build; it is that state, events, rendering and compact UI updates come as one architecture.
And notice what that first one is doing. State in one place, a diff against the previous state, minimal patches to the DOM. That’s React’s virtual DOM again, with the diffing moved out of the browser and into a server process. Same idea, one layer down.
The server pathWhat that buys, and what it costs
Both earlier problems change shape, but there are still tradeoffs. There’s no shared-module problem, because there’s no second copy. The socket provides one long-lived channel for events and UI updates. The network round trip does not disappear.
The tradeoffs are:
Latency. Every checkmark is a round trip, so it’s never 0ms – a user on a bad connection will notice that.
The server holds per-user state. LiveView and the BEAM are built around making that practical, but it is still capacity to plan for and state to recover after disconnects or deploys.
And one that has nothing to do with Elixir: validating on the server means the password itself crosses the network on every keystroke. On the browser path it never leaves the machine until submit. TLS covers it in transit, but server-side validation creates more opportunities for the value to reach logging or tracing unless those systems are deliberately scrubbed. That is just as true of htmx or Livewire.
A third positionOne language, both runtimes
Hologram takes trade one and applies it to a language the browser doesn’t speak. It compiles Elixir to JavaScript.
defmodule PasswordField do
use Hologram.Component
# transpiled to JS, runs in the browser at 0ms
def action(:validate, %{value: password}, component) do
put_state(component, :long_enough, String.length(password) >= 8)
end
# never leaves the server, called explicitly
def command(:check_breach, %{value: password}, server) do
put_action(server, :breach_result,
breached?: BreachCorpus.contains?(password)
)
end
end
Same bet as Zod, from the other side. Zod works because the language already runs in both places. Hologram makes one run in both places that otherwise wouldn’t.
Not free either. The duplication moves into the compiler, and you pay with client-side runtime and compiled code, more compiler machinery and a younger ecosystem. Hologram can import npm packages, but crossing into JavaScript is still an extra boundary. Ambitious, and far more machinery than a password checklist needs.
What design engineer means
So you see, sometimes designing a checkmark may mean you need to understand threads 😅. Of course, most of the time you don't need all this.
But we are in the AI era, and user interfaces are being redefined. We are interacting with apps, agents are interacting with apps, we are interacting with agents, agents are interacting with other agents, we are using agents to interact with apps, etc.
Some of these flows are genuinely new, some are fundamentally different (e.g. long-running loops with stochastic outputs). We basically need to come up with new ways of interaction and I am sure we're witnessing a revolution!
It does help to understand the possibilities and limitations deep under what's visible—or even without the "visible" part, when designing for agents.
Links
- Inline Validation in Web Forms, Luke Wroblewski, A List Apart, 2009. The original research and the five numbers.
- NIST SP 800-63B, current password guidance: length, blocklists, password-manager support and no composition rules.
- Don’t Block the Event Loop, Node’s own guide to its event loop, worker pool and CPU-heavy work.
- HTTP/2 and HTTP/3, which both reuse connections; HTTP/3 compresses fields with QPACK.
- Zod Mini, including the current bundle-size comparison.
- 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 JavaScript interop, including importing npm packages.