Engineering

Image and file uploads in a rich text editor, done properly

Uploading the file is the easy part. The hard parts are what happens when the upload fails halfway, when the user pastes a 12 MB screenshot, and when someone deletes the image but not the blob.

10 min read

Uploading a file is a solved problem. Uploading a file from inside an editoris not, because the image has to appear in the document immediately, at a position the user chose, and then quietly become a real URL — or visibly fail, without leaving a broken document behind.

Most of the difficulty is in the states between “user dropped a file” and “done”.

First: do not store base64 in your content

The path of least resistance is to read the file with FileReader, inline it as a data: URI, and save. It works instantly, needs no endpoint, and is wrong for anything real.

Data URIs are fine for a 1 KB inline icon or a temporary preview. They are not a storage strategy. Upload to object storage, store a URL.

The upload handler contract

A good editor hands you the file and gets out of the way. The shape is a callback you implement, which receives the file and returns a URL:

file_upload_handler: function (file, callback, optionalIndex, optionalFiles) {
    const form = new FormData();
    form.append("file", file);

    fetch("/api/uploads", { method: "POST", body: form })
      .then(r => {
        if (!r.ok) throw new Error("Upload failed: " + r.status);
        return r.json();
      })
      .then(data => callback(data.url))          // the editor swaps in the real URL
      .catch(err => callback(null, err.message)); // and the failure path matters just as much
}

The important property is that you own the transport. Your endpoint, your auth headers, your storage. An editor that only uploads to the vendor’s service is one you cannot deploy into a private network, and it makes your content availability depend on their uptime.

Note the fourth parameter. When a user pastes or drops several files at once you get called per file, and the index and full list let you show “3 of 7” rather than seven indistinguishable spinners.

The states people forget

A single upload has at least five outcomes, and the demo only covers one:

  1. In flight. A placeholder must occupy the correct position with roughly the right dimensions, or the text reflows when the image lands and the user loses their place.
  2. Succeeded. Replace the placeholder in place — not at the caret, which has moved.
  3. Failed. Remove the placeholder and say why, near the image, not in a toast that has already gone.
  4. Cancelled. The user hit undo, deleted the placeholder, or navigated away mid-upload. Abort the request and do not insert a URL into a document that no longer has a slot for it.
  5. Saved before it finished. Autosave fires while three uploads are in flight. What gets stored?

That last one is the one that bites. If autosave serialises a document containing placeholder markup, you have persisted a reference to something that does not exist. Either block save while uploads are pending, or strip pending placeholders on serialise. Decide deliberately — the default is usually to store the broken state.

Paste and drag-drop are the common path

Most images never touch a file picker. Users take a screenshot and press Ctrl+V, or drag from a folder or another browser tab. Handle all three, and note they behave differently:

Screenshots are also big. A retina screenshot of a full window is routinely 5–12 MB of PNG for something displayed at 600px. Downscaling client-side before upload — draw to a canvas at a sane maximum dimension, re-encode — is usually worth it, and lets you reject genuinely oversized files before spending the bandwidth.

Orphaned files: the slow leak

A user uploads an image, then deletes it from the document, then saves. The blob is now unreferenced and will stay in your bucket forever. Multiply by every draft anyone ever abandoned.

Three workable approaches, in increasing order of effort:

Whichever you pick, decide it before launch. Retrofitting reference counting onto a bucket with 400,000 unattributed objects is genuinely unpleasant.

What belongs on the server

Client-side checks are for user experience. Every one of these must also exist server-side, because the client can be bypassed entirely:

One accessibility detail worth building in

The moment to ask for alt text is when the image is inserted, because that is the only moment the author remembers what it shows. Retrofitting alt text later means someone guessing from a thumbnail.

Make the field present and easy, but be careful about forcing it: a required field trains people to type “image”, which is worse than empty because empty alt is the standard signal for decorative, skip this. Offer a genuine “this image is decorative” checkbox so the distinction is deliberate rather than inferred from laziness. An accessibility checker can then flag the images that are neither described nor marked decorative.

Alt text also has to survive everywhere the document goes — HTML, .docx, and a tagged PDF, where it becomes the /Alt on a Figure element. If your export pipeline drops it, the work the author did is thrown away silently at the last step.


RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.