Rich text editor autosave: save to your server without losing edits

RichTextEditor with its full toolbar and an editable document

Add server autosave to a JavaScript rich text editor without sending overlapping writes or showing ā€œSavedā€ too early. This guide supplies a tested save queue, an HTTP contract and a checklist for slow networks and conflicts.

A browser-local draft is different from a server save. For a local-only writing app, use the Markdown draft and download guide. Here the server owns the document and its version.

Define the save contract first

Load a document only after authorizing the signed-in user. Return its HTML and an opaque version in an ETag response header. On each update, the client sends that value in If-Match. The server must atomically compare the version and update both HTML and version. A read followed by an unguarded write is not sufficient.

  • Return a new ETag only after the write commits.
  • Return HTTP 412 for a stale version; do not overwrite another tab’s work.
  • Validate the body size and document permissions on every request. Use your application’s CSRF protection for cookie-authenticated writes.
  • Sanitize untrusted HTML on the server before rendering it elsewhere. An editor is not your application’s security boundary.

Use one save request at a time

Download the save queue module into your app. It groups edits after an 800ms pause, starts a save within five seconds of continuous typing when no request is active, and keeps the newest draft while a save is in progress. Slow saves remain sequential. Failures keep the draft pending; conflicts block subsequent writes until your app resolves them.

The queue calls your save function; it does not provide a server, authentication or collaborative merging. Initialize it with the HTML you actually loaded, not a placeholder.

Connect RichTextEditor to your endpoint

Start with the editor quick start. The following module assumes an initialized editor, an existing document at /api/documents/123, and status and retry controls on the page. The endpoint below is a contract to implement in your own server language, not a public RichTextEditor service.

<p id="save-status" role="status" aria-live="polite"></p>
<button id="retry-save" type="button">Retry save</button>
<script type="module">
import { createDraftSaver } from "/js/draft-autosave.mjs";
// Your initialized RichTextEditor instance:
const editor = window.editor;
const status = document.getElementById("save-status");
const retry = document.getElementById("retry-save");
const endpoint = "/api/documents/123";
const loaded = await fetch(endpoint, { credentials: "same-origin" });
if (!loaded.ok) throw new Error("Document could not be loaded");
let version = loaded.headers.get("ETag");
if (!version) throw new Error("Server must return an ETag");
const documentData = await loaded.json();
editor.setHTMLCode(documentData.html);
const messages = {
  unsaved: "Unsaved changes", saving: "Saving…", saved: "Saved",
  error: "Not saved. Keep this tab open and retry.",
  conflict: "Another version exists. Copy your work before reloading."
};
const saver = createDraftSaver({
  initialHTML: editor.getHTMLCode(),
  onStatus(state) {
    status.textContent = messages[state];
    retry.disabled = state !== "error";
  },
  async save(html) {
    const response = await fetch(endpoint, {
      method: "PUT", credentials: "same-origin",
      headers: { "Content-Type": "application/json", "If-Match": version },
      body: JSON.stringify({ html })
      // Add your application's CSRF token/header here.
    });
    if (response.status === 412 || response.status === 409) {
      throw Object.assign(new Error("Version conflict"), { code: "CONFLICT" });
    }
    if (!response.ok) throw new Error("Save failed: " + response.status);
    const nextVersion = response.headers.get("ETag");
    if (!nextVersion) throw new Error("Save response is missing its ETag");
    version = nextVersion;
  }
});
retry.disabled = true;
editor.attachEvent("change", () => saver.schedule(editor.getHTMLCode()));
retry.addEventListener("click", () => { void saver.flush().catch(() => {}); });
window.addEventListener("beforeunload", event => {
  if (saver.pending) { event.preventDefault(); event.returnValue = ""; }
});
</script>

Make failures visible and recoverable

ā€œSavedā€ means the latest queued HTML was acknowledged, not merely that a request started. An unsuccessful request leaves the latest draft in memory. Offer retry for network and server errors, and a copy or download action before reloading after a version conflict. Do not automatically replace the editor with a save response: that can move the cursor and destroy undo history.

This example is not offline persistence. Closing the tab or crashing the browser can lose unsaved work. A navigation warning is best effort and is not reliably delivered on mobile. Do not rely on a final network request during pagehide. Add a separate browser-local recovery draft if your application needs it, explain its privacy limits, and never treat that copy as proof of a server save.

Save images as durable URLs

Do not persist temporary upload placeholders as finished documents. Coordinate autosave with your upload workflow, and only mark the document fully saved when its images have durable URLs. See Node.js image uploads and saving rich text with images.

Test the failure paths

  1. Delay a save, type A then B then C, and confirm requests never overlap and the final saved document is C.
  2. Return HTTP 500 once. Confirm the error stays visible and retry sends the newest content.
  3. Open the same version in two tabs. Confirm the second stale save returns HTTP 412 and preserves local edits.
  4. Make a save succeed without an ETag. Confirm the UI does not claim success.
  5. Test long documents, image uploads, reloads, mobile backgrounding and expired sign-in sessions.

Continue with form validation, the save and load demo and autosave design tradeoffs.

Read next