DEV Community

Cover image for Your Autosave Interview Answer Needs a Race-Condition Test
Karuha
Karuha

Posted on • Originally published at aceround.app

Your Autosave Interview Answer Needs a Race-Condition Test

Most autosave bugs are not about debouncing. They happen when a slower request finishes after a newer edit and quietly overwrites the screen. The practical fix is small: give every edit a monotonically increasing revision, and let only the latest revision change visible state.

That rule is useful well beyond note apps. It applies to profile forms, spreadsheet cells, settings toggles, and draft editors. It is also a good technical-interview prompt because it tests whether you can reason about asynchronous work after the happy path is already working.

A diagram showing two edits, two save requests, and a revision check that ignores the obsolete response.

What is the race condition in autosave?

Picture a user typing two versions of a note:

  1. They enter "draft v1". The client sends save request 1.
  2. They quickly enter "draft v2". The client sends save request 2.
  3. Request 2 succeeds first, so the UI correctly shows "draft v2".
  4. Request 1 fails or succeeds later. If its handler blindly updates UI state, it can show an old error, clear a saved indicator, or restore old text.

Network responses are not guaranteed to arrive in request order. A debounce reduces the number of requests, but it does not establish an ordering contract once more than one request is in flight.

The contract we actually want is precise:

An operation may complete at any time, but only the latest user intent may update the UI.

That is a “last request wins” policy. It is a product decision as much as an implementation detail. For a single-user draft, the latest local edit is usually the right authority. For collaborative editing, use server versions or a conflict-resolution protocol instead; a local counter alone cannot reconcile two people editing the same document.

Build the smallest useful controller

Here is a framework-neutral controller. Keeping it outside React or an Android ViewModel makes the state transition easy to inspect and test. An adapter can call edit from an input handler and render the snapshots however it likes.

export function createAutosaveController({ initialValue, save, onChange }) {
  let state = {
    value: initialValue,
    revision: 0,
    status: "saved", // saved | saving | failed
    error: null,
  };

  const publish = () => onChange({ ...state });

  function edit(value) {
    const revision = state.revision + 1;
    state = { value, revision, status: "saving", error: null };
    publish();

    return save({ value, revision })
      .then((server) => {
        // A slow response is allowed to finish; it is not allowed to win.
        if (state.revision !== revision) return;

        state = {
          ...state,
          value: server.value, // keep server-side normalization if this is latest
          status: "saved",
          error: null,
        };
        publish();
      })
      .catch((error) => {
        // Do not turn an obsolete request into a visible failure.
        if (state.revision !== revision) return;

        state = {
          ...state,
          status: "failed",
          error: error instanceof Error ? error.message : "Save failed",
        };
        publish();
      });
  }

  return {
    edit,
    snapshot: () => ({ ...state }),
  };
}
Enter fullscreen mode Exit fullscreen mode

There are four details worth calling out.

Detail Why it matters
A revision is assigned before the request starts Every completion handler can identify the intent that created it.
The editor value is updated optimistically A save delay never makes typing feel delayed.
Both success and failure check the revision Ignoring only old successes still leaves stale error banners.
The failed state preserves the new value “Retry” should retry what the user just wrote, not silently restore an earlier draft.

The controller does not cancel the older request. Cancellation is still a good optimization when the transport supports it, but it is not the correctness mechanism here. A request can reach the server even after the client aborts it. The revision check makes late completion harmless either way.

Verify the failure that causes production bugs

A timeout-based test can miss this race because timing is not deterministic. Instead, use manually controlled promises and complete them in the worst possible order.

First save the implementation as autosave-controller.mjs, then run this test with Node:

import assert from "node:assert/strict";
import { createAutosaveController } from "./autosave-controller.mjs";

function deferred() {
  let resolve;
  let reject;
  const promise = new Promise((res, rej) => {
    resolve = res;
    reject = rej;
  });
  return { promise, resolve, reject };
}

// A rejected old save must not roll back a newer edit.
{
  const first = deferred();
  const second = deferred();
  const requests = [first, second];
  let next = 0;

  const controller = createAutosaveController({
    initialValue: "draft",
    save: () => requests[next++].promise,
    onChange: () => {},
  });

  const one = controller.edit("draft v1");
  const two = controller.edit("draft v2");

  first.reject(new Error("network lost"));
  await one;
  assert.deepEqual(controller.snapshot(), {
    value: "draft v2",
    revision: 2,
    status: "saving",
    error: null,
  });

  second.resolve({ value: "draft v2" });
  await two;
  assert.equal(controller.snapshot().status, "saved");
}

// The latest failure stays visible and preserves the user's draft.
{
  const request = deferred();
  const controller = createAutosaveController({
    initialValue: "before",
    save: () => request.promise,
    onChange: () => {},
  });

  const attempt = controller.edit("keep this");
  request.reject(new Error("offline"));
  await attempt;
  assert.deepEqual(controller.snapshot(), {
    value: "keep this",
    revision: 1,
    status: "failed",
    error: "offline",
  });
}

console.log("autosave controller: 2 race-condition checks passed");
Enter fullscreen mode Exit fullscreen mode

The test makes request 1 fail while request 2 is still saving. After the old failure settles, the screen must still contain "draft v2", remain in the saving state, and show no error. Then the second request completes and the state becomes saved.

The second case matters too: when the latest request fails, the error should be visible and the user’s current text should remain available for a retry. A green test suite that only covers “request succeeds” cannot prove either behavior.

Where should debounce, cancellation, and retry sit?

These tools solve different problems, so treat them separately:

  • Debounce controls request volume. Put it before edit if a user can emit dozens of updates per second.
  • AbortController reduces wasted network work for requests the user has superseded. Do not use it as proof that a late response cannot arrive.
  • Revision checking owns UI correctness. It decides which completion is allowed to change the screen.
  • Retry should send the current value with a new revision. Retrying an obsolete payload merely creates another stale operation.

In a React app, the controller can live behind a hook and publish through a state setter. In an Android app, the same four fields map naturally to a ViewModel state flow: value, revision, status, and error. The platform APIs change; the invariant does not.

How would I explain this in an interview?

Start with the user-visible risk, then narrow it:

“Two saves can be in flight even with a debounce. I make each edit a revision, optimistically render the newest value, and ignore every success or failure whose revision is no longer current. I would test it by resolving the newer request before the older one, then failing the older one.”

That answer communicates more than API familiarity. It names the asynchronous boundary, states an invariant, explains the trade-off, and includes a failure-oriented test.

A strong follow-up answer should also distinguish local ordering from data integrity. If the server accepts writes out of order, protect the API too—typically with an entity version, conditional update, or idempotency key. The UI revision prevents a confusing screen; it does not replace a server-side concurrency policy.

A five-minute practice drill

Take any existing form in your portfolio and answer these questions aloud:

  1. What can happen if save A finishes after save B?
  2. Which state transitions are allowed after the user types again?
  3. Which test proves that an old failure is invisible?
  4. What would change if two devices edited the same record?
  5. Where would you add cancellation without relying on it for correctness?

If you want to rehearse that explanation with follow-up questions rather than only read it, aceround.app — AI interview assistant is designed for interview practice. The useful goal is to defend your own state model, not memorize this particular implementation.

FAQ

Is a timestamp enough instead of a revision?

It can work when the clock is local and the value is used only for ordering client actions. A counter is simpler: it cannot collide inside one controller and does not depend on device time. Use server-issued versions when the server needs to enforce write ordering.

Should I roll the UI back when autosave fails?

Usually no. Preserve the draft, show a clear failed state, and provide retry. Rolling back discards the user’s latest work at exactly the moment the network is unreliable.

Does this replace conflict resolution?

No. This handles one client’s in-flight operations. Multi-user collaboration requires a server-authoritative approach such as optimistic concurrency control, operational transformation, or CRDTs, depending on the product.

Sources

Disclosure: AI assistance was used to draft and edit this post. The code example and its race-condition checks were run before publication.

Top comments (0)