The Race Condition That Deleted User Input

4 min read

The Bug

After release, users reported losing text while typing. The issue was impossible to reproduce at normal typing speeds—it only surfaced under rapid input.

We had a text area that synced with a persistent store. Type something, it saves. Refresh the page, it’s still there. Simple. Except sometimes, mid-sentence, the text would snap back to an earlier version.

This is the story of a race condition hiding in plain sight.


The Buggy Code

The component looked innocent enough:

function TextArea({ savedValue, onSave }) {
  const [value, setValue] = useState(savedValue);
  const [isDirty, setIsDirty] = useState(false);

  // Sync from store when not editing
  useEffect(() => {
    if (!isDirty) setValue(savedValue);
  }, [savedValue, isDirty]);

  // Reset dirty flag when values match
  useEffect(() => {
    if (savedValue === value) setIsDirty(false);
  }, [savedValue, value]);

  const handleChange = (e) => {
    setValue(e.target.value);
    setIsDirty(true);
    onSave(e.target.value); // debounced at 500ms
  };
}

The logic seemed sound:

  • Track whether the user is actively editing (isDirty)
  • Only accept external updates when not editing
  • Clear the dirty flag when the saved value catches up

But under rapid input, users lost their work.


The Race Condition

Here’s how it unfolds:

  1. User types “Hello” → value = "Hello", isDirty = true, debounced save queued
  2. User keeps typing “Hello World” → value = "Hello World", new save queued
  3. First save completes → savedValue updates to “Hello”
  4. Second effect fires: savedValue ("Hello") === value ("Hello World")? No, but the isDirty state change triggers a re-render
  5. During the re-render cascade, isDirty briefly becomes false (values matched at some point)
  6. First effect fires: !isDirty is true → setValue(savedValue) overwrites “Hello World” with “Hello”

The user’s latest input is gone.

The core flaw: using state for isDirty causes re-renders, and the “reset when values match” logic creates a timing window where stale data can slip through.


Why This Is Hard to Catch

This bug has several properties that make it elusive:

  • Timing-dependent — Only happens when saves complete while the user is still typing
  • Speed-dependent — Normal typing is usually fine; rapid input triggers it
  • Looks correct in isolation — Each effect makes sense on its own
  • No errors — React doesn’t warn you; the component “works”

The bug lives in the interaction between pieces, not in any single piece.


The Fix

function TextArea({ savedValue, onSave }) {
  const [value, setValue] = useState(savedValue);
  const isDirtyRef = useRef(false);       // no re-render on change
  const lastSentRef = useRef(savedValue); // track what we sent

  // Only accept external updates when not dirty AND not an echo of our own save
  useEffect(() => {
    const isEcho = savedValue === lastSentRef.current;
    if (!isDirtyRef.current && !isEcho) {
      setValue(savedValue);
    }
  }, [savedValue]);

  // Clear dirty flag only when our save is confirmed
  useEffect(() => {
    if (savedValue === lastSentRef.current) {
      isDirtyRef.current = false;
    }
  }, [savedValue]);

  const handleChange = (e) => {
    const newValue = e.target.value;
    setValue(newValue);
    isDirtyRef.current = true;
    lastSentRef.current = newValue;
    onSave(newValue);
  };
}

What Changed

  • Ref instead of state — Dirty tracking doesn’t trigger re-renders, breaking the cascade
  • Echo detection — Ignore updates that are just our own saves bouncing back
  • Completion-based clearing — Only clear isDirty when savedValue matches what we last sent, confirming the save completed

Why Both Patterns?

Why keep isDirtyRef if we have echo detection?

Because echo detection only blocks our own saves bouncing back. External updates (from another tab, another user, or a sync) are legitimate changes—we still need to block those while the user is actively typing.

The two mechanisms handle different cases:

  • Echo detection → “Ignore my own saves coming back”
  • Dirty flag → “Ignore external changes while I’m typing”

The General Pattern

This bug appears whenever you have:

  1. Two-way sync between local state and external state
  2. Async updates that complete out of order
  3. State-based flags that trigger re-renders

The fix pattern:

  1. Use refs for coordination flags — Avoid re-renders for tracking state
  2. Track what you sent — Distinguish your own updates from external ones
  3. Clear flags on confirmation, not on match — “Values equal” doesn’t mean “my save completed”

The Lesson

A system that is fast, reactive, and correct in isolation can still fail when those pieces interact—not because any single part is wrong, but because the boundaries between them are undefined.

In reactive systems, correctness depends less on individual components and more on how they interact. The bug wasn’t in the store, or the effect, or the state. It was in the space between them.


This bug occurred while building a draft system with TinyBase and IndexedDB. Read about that architecture →

Read more
#becoming #wandering #working #discovering