Files
typewriter/docs/notes/ctrl-g-perceived-latency.md
Julien Calixte 011f79f134 docs(notes): add Ctrl-G perceived-latency essay
Long-form rationale for the "durability before delivery" framing —
why a 10-second network operation in response to a single keystroke
doesn't have to feel slow. Establishes docs/notes/ as the home for
shareable design rationale (not authoritative spec).
2026-05-17 12:54:04 +02:00

6.6 KiB
Raw Blame History

Durability before delivery

Why a 10-second keystroke on a typewriter I'm building doesn't have to feel slow.

Two keystrokes, very different costs

I'm building a small device called Typoena: an e-ink panel, a mechanical keyboard, an ESP32-S3, and a single purpose. You open the lid, you write Markdown, and you press a key to publish it to GitHub. There is no browser, no notification tray, no second app. The hardware enforces focus the way software can't.

The whole product surface is two user-facing actions:

  • Save (Ctrl-S) — write the current buffer to the SD card. Always available. ~200 ms.
  • Publish (Ctrl-G) — ship the entire tracked working copy to the git remote. Atomic from the user's view. 510 seconds typical.

These two keystrokes look symmetric. Both are modifier-letter, both triggered the same way, both single-shot. But one of them takes 200 ms and the other takes 510 seconds. That's a 50× cost gap, and it matters because the human perception threshold for "instant" is about 100 ms.

The physical keystroke itself — key down, debounce, USB report, key up — takes ~150 ms. On Ctrl-S that's most of the perceived time. On Ctrl-G, the keystroke is barely the first sliver of a long process.

Where the time goes

Here's a breakdown of Ctrl-G on a fresh session, with the Wi-Fi radio starting cold:

Stage Time
Save buffer to SD ~0.1 s
git add + git commit ~0.2 s
Wi-Fi associate + DHCP 25 s
TLS handshake ~2 s
git push (pack + send + server) 13 s
Total (critical path) 510 s

Wi-Fi association dominates, and that's deliberate. Typoena's radio is off by default — it only powers up when Ctrl-G is pressed, and it shuts down again after a short grace window. The battery savings are dramatic: always-on station mode would burn ~410 mAh/day on the radio alone; on-demand drops that to ~25 mAh/day at ten Publishes per day. On a single 18650 cell, that's the difference between days and weeks of standby.

The price is paid on every cold Publish. A few seconds, every time. For a device that, by design, doesn't need to be online except when shipping work, this trade is fine. But it produces the asymmetry above: one keystroke costs you a fifth of a second; another costs you ten.

The question I was sitting with

After mapping all this out, I asked the question that triggered the rest of this post:

"I'm concerned about the fact that Ctrl-G is a 150ms action to do, but what it triggers can take >5-10s. Compared to the same quick action Ctrl-S for instance that will have a order of magnitude even lower than the pressing key action."

My first instinct was to optimise. TLS session resumption could shave a second. A smaller cipher suite, another. Static IP instead of DHCP, a few hundred milliseconds. With effort, I might cut the cold path to 45 seconds.

But that's still 25× the Ctrl-S cost, and every optimisation comes with friction. TLS resumption requires storing session tickets across radio power-cycles (more state, more code). Cipher tuning sacrifices flexibility on networks I haven't tested. Static IPs are fragile when the user moves between routers. I'd be spending design budget on a number that, even halved, still feels slow.

So I asked a different question: what is the user actually waiting for?

A different question

When you press Ctrl-S, the moment you care about is "my work is saved." The SD card writes the bytes in 50200 ms, and that moment lines up with the operation completing. Save = safe. Same instant.

When you press Ctrl-G, what's the equivalent? You'd naturally say "my work is published" — and assume that means the push completed. But this device authors timestamp commits before it pushes. The local commit lands at ~0.2 seconds, and from that moment on your work is preserved across power loss, SD removal, the apocalypse — everything except remote delivery. The remaining 510 seconds is transport of an already-safe thing. The work isn't in flight; it's already committed to disk. The push is just delivery to a backup location.

Durability before delivery

This is the design principle the question pushed me toward: the moment that matters to the user is the moment durability is achieved, not the moment delivery completes. Once I named it, the implementation became obvious.

The status line surfaces the commit-landed state at ~0.2 seconds, then shows the push as a secondary state:

Bad:   "publishing… 1 of 3 ▓░░"        ← misleading, conflates safe + delivered
Good:  "✓ committed abc1234 · pushing" ← says exactly what's done and what's pending

Two transitions, two messages, partial-refresh on the status line only. The user sees the within a fifth of a second of pressing the key. They know the work is safe. They can keep typing. The radio continues associating, the TLS handshake completes, the push lands — all of it happens around them, none of it modal.

The perceived latency of Ctrl-G collapses from 10 seconds to roughly 200 milliseconds. The gap with Ctrl-S is no longer 50×; it's barely distinguishable.

Why this generalises

I think this is a useful lens beyond writing appliances. Any application that does network I/O in response to a single user action has the same shape: a fast local operation followed by a slow remote one. The usual responses are:

  1. Optimise the slow part until it feels fast. Often impossible.
  2. Hide the slow part with a spinner. Admits defeat — and on e-ink, with ~300 ms refresh and ghosting, you can't even spin.
  3. Quietly do the operation in the background and not tell the user. This is the auto-sync trap I explicitly designed Typoena to avoid — the device is a writing tool, not a sync engine.

The fourth path — name the durability moment, surface it the instant it arrives — is almost always available. It shifts the question from "how fast is the operation" to "when is the user safe." Those are different questions with different answers, and the second one is almost always faster.

What I'm not optimising

I'm not chasing a v1.0 target of Ctrl-G in ≤10 seconds on the cold path. With the safety moment landing at ~200 ms, that target is no longer the load-bearing UX metric. I'd rather spend the engineering effort on something the user can actually feel: typing latency, partial-refresh ghosting, keyboard wake time.

Durability before delivery. Once you see it, you can't unsee it — and suddenly the slow operations stop feeling slow.