DEV Community

Teasa team
Teasa team

Posted on

Autosave without lost typing: merge against the submitted snapshot

A save request starts with title = "First draft". While it is in flight, the writer changes the title to "Second draft". The server then acknowledges the first save.

If the response handler replaces the editor state with the response body, the writer watches their new title disappear. The server can be working perfectly while the browser loses work.

Debouncing reduces request frequency. It does not settle this race. The useful unit of comparison is the exact snapshot that each request submitted.

Keep three versions of the document

At acknowledgement time, distinguish:

Name Meaning
sent The snapshot submitted by this request
current What the editor contains now
saved What the server accepted, including normalization

For each editable field, accept the server's value only when the current value still matches the submitted value. Otherwise, keep the newer local edit.

That lets a server trim whitespace from an untouched title without replacing a paragraph typed during the request. It also preserves a deliberate deletion: an empty string is an edit, not a missing value.

A small merge function

This example uses a fixed form with three required string fields. Keeping that scope explicit makes equality straightforward. Save it as merge-save.mjs:

const fields = ["title", "summary", "body"];

export function mergeAcknowledgement(current, sent, saved) {
  const next = { ...current };
  for (const field of fields) {
    if (current[field] === sent[field]) {
      next[field] = saved[field];
    }
  }
  return next;
}
Enter fullscreen mode Exit fullscreen mode

The caller must capture sent before starting the request. For this flat form, copying the object is enough. For nested cloneable data, structuredClone creates an independent snapshot.

When the response arrives, read the latest editor state. In React, that means a functional state update rather than the value captured by an older render:

// Inside the editor's serialized save coordinator:
const sent = { ...latestDraft };
const saved = await saveDocument(sent);
setDraft(current => mergeAcknowledgement(current, sent, saved));
Enter fullscreen mode Exit fullscreen mode

This snippet shows the acknowledgement step. The coordinator still owns request ordering and error handling.

Test the timeline, not just the happy path

Save this beside the module as merge-save.test.mjs, then run node --test merge-save.test.mjs:

import test from "node:test";
import assert from "node:assert/strict";
import { mergeAcknowledgement } from "./merge-save.mjs";

test("accepts normalization while preserving newer typing", () => {
  const sent = { title: " Draft ", summary: "Before", body: "Text" };
  const current = { ...sent, summary: "Typed during save" };
  const saved = { ...sent, title: "Draft" };

  assert.deepEqual(mergeAcknowledgement(current, sent, saved), {
    title: "Draft", summary: "Typed during save", body: "Text"
  });
  assert.equal(current.title, " Draft "); // Inputs were not mutated.
});

test("preserves a deletion made during the request", () => {
  const sent = { title: "Draft", summary: "Remove me", body: "Text" };
  const current = { ...sent, summary: "" };
  assert.equal(mergeAcknowledgement(current, sent, sent).summary, "");
});

test("a second acknowledgement can persist the newer edit", () => {
  const first = { title: "One", summary: "", body: "Text" };
  const current = { ...first, title: "Two" };
  const merged = mergeAcknowledgement(current, first, first);
  const secondSent = { ...merged };
  assert.deepEqual(
    mergeAcknowledgement(merged, secondSent, secondSent), current
  );
});
Enter fullscreen mode Exit fullscreen mode

The last test captures an important consequence: preserving an edit locally does not mean that edit has reached the server. After the first acknowledgement, the editor remains dirty if its content differs from the accepted server state.

Serialize saves, and version the server write

Keep at most one save in flight per document in a given editor. Edits made during that request become the next snapshot after its acknowledgement. On failure, keep the draft and expose retry; do not label it saved.

A single browser coordinator cannot order writes from another tab or device. Include an expected document version in each write, and have the server atomically reject an outdated version. Handle that conflict separately from a transport failure. Repeatedly sending an old version does not resolve it.

Store server-owned metadata, such as the accepted version, separately from editable fields. Advance that metadata after a successful serialized acknowledgement, even if newer local text remains dirty.

For nested collections, choose identity and merge semantics deliberately. An image list may need to merge membership by asset ID while preserving a newer local crop. Treating the entire array as one field can hide that distinction. This small string-form function is a starting point, not a universal document merge algorithm.

The key assertion to add to an autosave test is simple: begin a save, type something else, complete the old request, and verify that the new text is still there.


We build Teasa, an app for interactive AI stories and Character chat. Its Creator Studio lets people write characters, casts and story openings, which makes preserving edits during a save a concrete product requirement.

To see what those drafts become, explore interactive AI stories on Teasa.

Top comments (3)

Collapse
 
susanacharya profile image
Susan Acharya •

the per-field comparison is the clean part, only accepting the server's value when current still equals sent. one thing worth adding to the tests is a field that the server normalizes (trailing whitespace) while the user is mid-typing in a different field, since that's where a whole-object replace would still bite. does the coordinator queue saves or drop to the latest one?

Collapse
 
sgaggjhkjh profile image
Arjun Sharma •

Comparing current against sent before accepting the server value is a clean way to protect typing made during a request. I would also test the empty string case explicitly, since a deliberate deletion should survive a save just like any other edit.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

the empty-string detail is the one most autosaves would miss.