ClaroCal has a quick-add box. You type a task the way you would say it, “brush teeth tomorrow”, and a row of chips appears under the input labelled “ClaroCal sees”: Tomorrow, and, as of a recent release, ~5 min. You never typed a duration. The planner knew one anyway, from a corpus of 478 everyday tasks it has learned typical durations for.
That little chip took longer to design than to build. Not because the code is hard, but because it sits exactly on the fault line between two things users want at the same time: an interface that responds instantly, and an interface that never lies to them.
I want to walk through how we resolved that, because the trade-off shows up everywhere: search suggestions, price estimates, shipping dates, “people also bought”. Any time the truth lives on a server and the user’s eyes live on a screen, you are making this exact decision, whether you notice or not.
The setup: the number lives on the server
The duration corpus is a lookup table: “brush teeth” is 5 minutes, “make dinner” is 45, “go for a run” is 45, across 20 categories of everyday tasks. When you create a task without typing a duration, the server matches your title against it and stores the matched number instead of a flat default.
The obvious move is to ship that table to the browser so the chip can be instant. We can’t. Our client bundle lives under a hard size ceiling enforced by CI, and it currently sits at 89% of it. A corpus plus a matcher does not fit, and the ceiling is not negotiable. So the truth stays server-side, and the chip has a problem: the input box knows what you typed, but not what it means.
For a while we did the honest-but-invisible thing: the server quietly applied the right duration at create time and the chip said nothing. Correct, and terrible. Users saw a 5-minute block appear on their calendar for “brush teeth” and wondered where the number came from. A correct answer you cannot see is indistinguishable from a bug.
So the chip had to show the number. The question was how it should arrive.
Four ways a chip can arrive
We built a throwaway prototype with all four candidates behind a latency slider, so we could feel each one instead of arguing about it in a document. Here they are on a shared clock:
Typing "brush teeth tomorrow"…
Submit only. No chip. Press Add, and the confirmation toast tells you what happened: “Added, 5 min.” Zero latency problem because there is zero promise. But the corpus stays invisible until after the moment you could have corrected it, which is the invisible-correctness bug all over again.
Client hot-list. Ship a small subset of the corpus to the browser and match locally. Instant. Also a trap: it is a second matcher, and two matchers drift. The day the chip says 15 and the created task says 20, the user has caught the product contradicting itself, and they were right to. In my experience that class of bug is the most expensive kind, because no error is thrown anywhere. Both numbers are “correct” according to the code that produced them.
Optimistic placeholder. Show a dimmed “~30 min” immediately, then firm it up when the server answers. This one feels great in a demo and worst in practice, for a reason psychologists have a name for: anchoring. The first number a person sees sticks. If the placeholder says 30 and the truth is 5, the correction registers as the interface changing its mind, not as the answer arriving. Optimistic UI is the right tool when the optimistic value is almost certainly true, like a “like” button. It is the wrong tool when the value is a guess about an unknown.
Debounced fetch. Wait for a pause in typing, ask the server, show what it says. The chip arrives roughly 250 to 500 milliseconds after you stop typing, and it is the only variant where the number shown is always the number stored.
We shipped the fourth one. The rule that decided it was already written in a comment in the codebase, about a different chip: a chip that guessed its own number would be worse than no chip, because the chip is the confirmation.
What a debounce actually is
If you already know, skip ahead. If not: a debounce timer is a polite listener. It waits until you stop talking before it responds, and every time you say another word it goes back to waiting. In code, every keystroke cancels the previous timer and starts a new 250 millisecond one. Only a pause long enough for a timer to survive sends a request.
Every keystroke resets the 250 ms timer. Only the pause gets a request.
Why 250 milliseconds? It is close to the natural gap between typing bursts. Much shorter and you fire requests mid-word; “bru”, “brush t”, “brush tee” all hit the server for nothing. Much longer and the pause becomes perceptible as lag. The classic usability numbers still hold up: under about 100ms feels instantaneous, under about one second keeps you in flow, and past that people start thinking about the interface instead of their task. A chip that lands 250 to 500ms after your last keystroke sits comfortably inside the flow window, and, this is the part that surprised me, it reads as considered rather than slow. The pause tells a tiny true story: it looked something up.
The whole mechanism is a dozen lines:
// every keystroke cancels the last timer; only a pause survives
let timer: ReturnType<typeof setTimeout>;
function onInput(title: string) {
clearTimeout(timer);
timer = setTimeout(async () => {
const match = await memoised(title); // same matcher the create path uses
if (match) showChip(`~${match.minutes} min`);
}, 250);
}
Two details do most of the engineering work:
Memoisation per title. Every answer is cached against the exact title it was asked for. Backspace “brush teeth” to “brush tee” and retype it, and the chip reappears from cache with zero requests. In a normal session most keystrokes cost nothing.
Typed durations win locally. If you write “finish the deck for 90m”, the parser in the browser already knows the duration. No timer, no request, instant chip, no tilde. The server round-trip exists only for the case where the server genuinely knows something the browser does not.
And one detail does the correctness work: the request sends the same parsed title that the create path stores, through the same matcher. The chip is not an estimate of what the server will probably do. It is the output of the code that will do it. Truthfulness is not a QA checklist item here; it holds by construction.
The tilde is doing real work
The chip says “~5 min”, not “5 min”. One character, but it carries the whole honesty contract: this number was inferred, not typed. When you type the duration yourself, the tilde is gone. It is a small thing, and small things are what trust in a planner is made of. ClaroCal’s job is to schedule your day with numbers you mostly did not provide. Every surface that touches an inferred value has to be clear about which values are yours and which are its.
That is also why the wrong-number failure mode mattered more to us than the slow-chip one. A chip that is late by 300 milliseconds costs you nothing; you were still typing anyway. A chip that is wrong once makes you double-check every chip afterwards, and a confirmation you have to confirm is not a confirmation. Speed problems annoy people. Trust problems compound.
Takeaways
- Decide what the element is before you optimise it. Our chip is a confirmation, so correctness beats latency. An autocomplete suggestion is a guess, so latency beats correctness. Most latency arguments are really disagreements about which one you are building.
- Late beats wrong, but invisible loses to both. Silent server-side correctness read as a bug to real users. If the system knows something, show it while the user can still react.
- Anchoring is not a footnote. A placeholder number is not neutral. Whatever you show first is what the user believes; a correction reads as a contradiction.
- One matcher, one truth. The moment you have two implementations of the same inference, you have scheduled a future disagreement between them. Feed the display path and the write path through the same code.
- Feel it before you argue about it. The four-variant prototype with a latency slider settled in an afternoon what the design discussion had circled for days. Latency decisions are physical; make a thing you can poke.
- Spend your milliseconds where they buy trust. We could have made the chip instant. Instant and occasionally wrong turned out to be the worst product of the four.