All articles
Sep 30, 20269 min readBackend Engineering

How This Blog Prevents Conflicting Edits

A look inside this blog's editor: revision checks, conflicting saves, local edits, limited history, and the boundary between atomic writes and transactions.

A blog with one author sounds like a fairly simple application.

There is an editor. There is a save button. The content goes into a database, and the website displays it.

But let me add one small detail: I open the same article in two tabs.

Now there are two copies of the article, two sets of local changes, and potentially two requests trying to save different content. The database does not know that both requests came from the same person. It still has to decide which changes are allowed to replace the stored version.

This blog handles that problem with revision numbers and conditional writes. The interesting part is how that decision connects to the editor, revision history, and deletion.

One author, two different versions

Let's take a simple example.

Both tabs load revision 7 of an article. In tab A, I improve the introduction. In tab B, I rewrite the conclusion.

Tab A saves first. The stored article becomes revision 8.

Tab B still holds the old introduction from revision 7. If its save blindly replaces the article, the new conclusion arrives, but the improved introduction disappears. Both requests could return success, even though one change was lost.

That is a lost update.

The editor sends the revision it loaded along with the article. A save from tab B therefore means: "Save this content if the article is still at revision 7."

The answer is now a conflict because the stored article has already moved to revision 8.

Two tabs attempt to save the same article revision

This catches stale edits. It does not combine the two versions. Choosing how to reconcile the introduction and conclusion remains a separate problem.

The check belongs inside the database write

The save function first reads the article and compares its revision with the expected revision. That makes obvious conflicts easy to detect.

But a read followed by a check is not enough.

Two requests can both read revision 7 before either one writes. Each request passes the initial check. There is still a race between reading the document and updating it.

The important protection is the update filter itself. It includes the article's slug and the revision that must still exist when the write happens.

Here is a simplified illustration of that part of the implementation. Authentication, validation, history, legacy records, and the other article fields are omitted:

typescript
const result = await posts.updateOne(
  { slug, revision: expectedRevision },
  {
    $set: {
      content: newContent,
      revision: expectedRevision + 1,
      updatedAt: new Date(),
    },
  },
);

if (result.matchedCount !== 1) {
  throw new Error("The article changed before this save.");
}

The condition and the changes are part of one document update. After one request moves revision 7 to revision 8, another update requiring revision 7 cannot match.

This is the compare-and-set idea: compare the current state with what I expected, then change it only if that expectation still holds. MongoDB documents this behavior in its guide to atomic writes.

In the actual code, a failed match becomes a PostRevisionConflictError. The database check remains necessary even after the earlier application check.

What the conflict means in the editor

The save route translates that error into HTTP 409 Conflict and a message asking me to reload the article before saving again.

There is an important distinction here. The server rejected the save, but the ordinary save handler does not replace my local content with the server's article. The edited text remains in the open editor, and the save state becomes an error.

That gives me an opportunity to copy the changes somewhere safe, reload the current article, and reapply them deliberately.

Refreshing immediately is not a recovery strategy. The unsaved article is held in the page's memory. The editor does not store a recoverable content draft in local storage, and it does not provide an automatic merge screen. Reloading can discard those local edits.

Sending the same content again with the same stale revision also does not resolve the conflict. The request still describes the same outdated starting point.

The useful response is to preserve the local work and inspect the version that won.

Typing while a save is travelling

There is another smaller race inside a single tab.

Suppose an ordinary save starts, and I continue typing before its response returns. Replacing the editor with the returned snapshot would remove the text I just added.

The editor tracks a local edit counter. A save captures that counter along with its content snapshot. When the response arrives, it checks whether more edits happened during the request.

If nothing changed locally, the returned article can replace the editor state. If I continued typing, the editor keeps the newer local fields and takes only the returned revision, update time, and publication time. It remains marked as having unsaved changes.

That local counter and the database revision solve different problems. One protects typing during an ordinary save response; the other protects the stored article from stale requests.

This response handling applies to the ordinary save path. It should not be assumed to cover every publish, restore, or other asynchronous action.

Creating, publishing, and timestamps

A new editor starts at revision 0. A successfully created article starts at revision 1.

The creation path uses an upsert, and the configured unique slug index prevents two documents from claiming the same slug. Competing creation attempts are converted into conflicts. Older imported records without a revision are treated as revision 0, with a database filter that checks the field is still absent.

Drafts autosave after approximately 1.2 seconds of inactivity once they have a title and slug, provided loading or series operations are not blocking the save. Published articles require an explicit save for their edits.

Publishing and unpublishing use the same revision-aware save function. These actions change stored state, so they also advance the revision. A stale tab cannot safely unpublish an article merely because it knows its slug.

The timestamps have separate jobs. createdAt survives ordinary updates. updatedAt changes on a successful save. publishedAt is established when publishing and preserved when the article returns to draft or is published again through these routes.

The revision is the concurrency condition. A timestamp displayed as "Edited" is useful information, but it is not the permission to overwrite an article.

History is deliberately small

Before updating an existing article, the save function records the current stored version in the post_revisions collection. It uses the slug and revision to identify that snapshot and $setOnInsert to avoid replacing an existing snapshot.

After the conditional article update succeeds, old snapshots are pruned. The normal retention policy is three previous revisions plus the current article.

For example, after a normal save produces revision 7, the history view can show:

VersionWhere it lives
7Current article
6Stored snapshot
5Stored snapshot
4Stored snapshot

Earlier versions are outside that retained window. Pruning failures are logged without turning the already successful article update into a failure, so extra stored snapshots can temporarily remain. The history query still requests at most three stored snapshots.

These snapshots provide limited recovery. They are not an unlimited backup or an immutable audit log; a managed series rename can update series metadata in existing history records.

Restore creates a new draft

Restoring revision 5 while the current article is revision 7 does not turn the counter backwards.

The restore function reads the selected snapshot and passes its article fields into the normal save function, with revision 7 as the expected current revision. If successful, the restored content becomes revision 8.

It also becomes a draft, even if the selected snapshot was published. If the current article was public, restoring moves it out of the public article set until it is published again.

The previous current version is snapshotted through the normal save path, subject to the same retention policy. Restoration is another change with its own revision.

The editor refuses to start restoration while it has unsaved changes. The server separately checks the expected current revision. If another tab changes the article before the restore completes, the restore must also face the conditional write check.

Atomic does not mean the whole save is a transaction

This is the boundary I find most important to explain accurately.

The conditional update of the current article is atomic. However, an ordinary save also touches history and deletion markers. Those separate operations are not wrapped in one database transaction.

The order for an existing article is roughly: record its previous version, conditionally update the article, prune history, then clear a deletion marker for that slug.

Consequently, a snapshot can be recorded even if the article update later loses a race. A failure after the article update can also leave the client uncertain about whether the content was committed. A network error is not proof that nothing changed.

In that situation, I need to inspect the current stored revision before deciding what to retry. Compare-and-set prevents a stale retry from silently overwriting a newer version; it does not make the entire request exactly-once.

That protection assumes the same article lifetime. Recreating a deleted slug starts its counter again. A separate generation identifier would be needed to distinguish an old tab from a newly created article with the same slug and revision.

Deletion has a different boundary. It runs in a transaction that reads and checks the article, deletes the expected revision, removes its history, and records a deletion marker. Those database changes commit together on a deployment that supports transactions. MongoDB explains this distinction in its Node.js transaction guide.

The marker lets the local-content import script skip deliberately deleted slugs. It contains deletion metadata, not a restorable copy of the article. Deletion removes the retained history too.

Public-page cache invalidation happens afterward in the route. That framework operation is outside the database transaction.

The tests focus on the failure paths

The existing tests check more than whether a save returns content.

They assert that the update filter contains the revision, that a failed conditional update raises a conflict without pruning history, and that history retrieval includes the current article and at most three snapshots. They also cover stale deletion, legacy records without revisions, and the deletion transaction's use of its session.

Route tests check that conflicting status changes and deletions become 409 responses. Editor tests cover explicit publishing and cancelling a switch away from unsaved edits.

These are focused tests with mocked database behavior. They document useful expectations, but they are not a real concurrent load test against MongoDB.

Conclusion

What I like about this design is that a save has a clear expectation: "I edited this version, and I want to replace it if it is still current." The revision check makes that expectation part of the database write.

When another tab moves the article forward, an ordinary save reports a conflict and keeps my edits in the open editor. I still need to copy them before reloading and decide how to reapply them.

The three previous snapshots give me a small recovery window. Restoring one creates a new draft revision while retaining the previous current version within that window.

For me, this makes the limits easier to understand: stale edits need attention, local changes need care, and history offers a bounded way back.

Thanks for reading.

This is part of an ongoing notebook about building dependable systems and understanding the machinery beneath the abstractions.