Started Before the Analysis Finished — An Event That Beat My Own Write
We analyze a new task's difficulty and, for the heavy ones, ask for a plan before you start. But the gate didn't fire even after analysis finished. Blocking didn't help either. One log line pointed at a server event that arrived before my own cache write.
Started Before the Analysis Finished — An Event That Beat My Own Write
fecit has a quiet stance: heavier work deserves one extra step. When you create a task, the server gauges its difficulty from the title, and if it looks substantial, it nudges you to sketch a plan before starting. It stays out of the way for small chores and only pauses you on the genuinely heavy ones.
This time, that gate refused to show — even after the analysis was done.
The gate didn’t fire
The symptom was simple. Create a hard task, wait a beat, tap start — the gate should appear, but the task just starts. Yet if you start it, revert to Registered, and tap again, the gate shows the second time.
“Fails once, works a moment later” is almost always timing. The difficulty signal wasn’t on the task yet when I tapped start, and the gate only fires when the signal is present — so it sailed right through.
I added a block, and it still leaked
So the idea was: hold state changes until the analysis lands. A freshly created task blocks start/complete until its signal arrives, showing a tiny spinner. Analysis is usually instant, so it’s a block short enough that no one notices.
To avoid locking forever, I added a timeout as a safety net — if the signal never comes, the block releases after a few seconds.
And it still leaked. The spinner stopped, I tapped start, no gate. This is the moment you’re tempted to just raise the timeout. Was 3 seconds too short? Try 5? — but that’s a guess. Bumping a number without knowing whether the analysis is genuinely slow or whether something else is leaking is gambling.
Measure, don’t guess
So I planted logs in three places: when the server finishes analysis and emits the signal, when the signal reaches the client (plus elapsed since creation), and when the block releases by timeout.
The server log answered first.
[AnalysisTiming] start task=6a3e…54bf
[AnalysisTiming] emit task=6a3e…54bf should_nudge=True elapsed=0.16s
Analysis finished in 0.16s and the signal was emitted correctly. The server was innocent. 0.16s is far inside the timeout, so that signal should have flipped the gate on. The client log named the real culprit.
event arrived task=6a3e…54bf should_nudge=true … (NOT in cache)
timeout unblock task=6a3e…54bf shouldNudge=undefined
The event arrived. But at that instant, the task I’d just created wasn’t in the local cache yet. The handler tried to find it to attach the signal, couldn’t, and walked away. The signal was dropped, the block released by timeout only, and the gate passed without ever seeing it.
An event that beat my own write
It was a race. When you create a task, the client sends a create request, gets the response, and writes it to the local cache. Meanwhile, right after creating it, the server analyzes difficulty in the background and emits a realtime event in 0.16s.
The catch: that event arrived before the client’s own create response and cache write. Before I’d finished saying “this task is written to the cache,” the server already said “this task’s analysis is done.” The handler received an id that wasn’t in the cache, found nothing, and discarded the signal.
With realtime sync it’s easy to think in one direction only — “my change → server → other devices.” Here a server event outran my own local write, on the same device. The order events arrive in and the order my own state becomes ready are not guaranteed to line up — common, and easy to forget.
Don’t drop it — retry
The fix direction was clear. If the signal arrives and the target isn’t there yet, don’t drop it — wait briefly until it shows up.
const applyResult = (taskId, nudge, attempt = 0) => {
const task = cache.getByIdSync(taskId);
if (task) { // in cache now — apply
task.shouldNudge = nudge;
upsert(task);
return;
}
if (attempt >= 12) return; // 250ms × 12 ≈ 3s cap
setTimeout(() => applyResult(taskId, nudge, attempt + 1), 250);
};
The gap between creation and the event is closed in one or two retries at most. The moment the task lands in the cache, the signal attaches and the gate fires on time. The timeout only catches the rare case where the signal truly never comes.
And don’t flash
One thing remained. With the race fixed, the normal path was so fast the spinner flashed and vanished. Showing a spinner for a sub-second block is more distracting than helpful.
So the indicator now appears later than the block — only when things are genuinely slow. The block engages immediately, but the spinner and the “analyzing” cue only show past a threshold. On the fast normal path nothing appears; only when analysis is slow does the fecit star briefly show up to look the task over — and once a star’s pulse has started, it finishes that one breath before fading, even if the analysis completes mid-cycle.
What I learned
-
Don’t fix timing bugs by guessing at numbers — measure. “Was the timeout too short?” is a hypothesis. Log the server emit time, the event arrival time, and the release path, and the culprit shows itself at once. One log line beats days of guessing.
-
A realtime event can be faster than your own local write. Think only “my change → server → other devices” and you’ll miss the case where a server event outruns your own cache write on the same device. Arrival order isn’t guaranteed.
-
Don’t drop a signal just because its target isn’t there yet. Instead of discarding it when not found, retry briefly — but a bounded retry, not an unbounded one.
-
A safety net must not pre-empt the normal path. A timeout is insurance against a lost signal; if it fires first and beats the real signal, the intended behavior leaks. Give the normal path enough room to finish before the net kicks in.
-
Don’t show what’s fast. A spinner on a sub-second state leaves only a flicker. Reveal the indicator only when it’s slow enough, and the common case stays smooth while the slow case gets its explanation.
A small gate meant to pause you in front of hard work ended up re-teaching an old truth: my own event can arrive before my own write. Once fixed, the gate shows on time — and when things are fast, it’s as quiet as if nothing happened.