Jun 11, 2025·8 min read

Optimistic locking for admin tools: prevent silent overwrites

Learn optimistic locking for admin tools with version columns and updated_at checks, plus simple UI patterns to handle edit conflicts without silent overwrites.

Optimistic locking for admin tools: prevent silent overwrites

The problem: silent overwrites when many people edit

A “silent overwrite” happens when two people open the same record, both make changes, and the last person to click Save wins. The first person’s edits disappear with no warning and often no easy way to recover.

In a busy admin panel, this can happen all day without anyone noticing. People keep multiple tabs open, jump between tickets, and come back to a form that’s been sitting there for 20 minutes. When they finally save, they aren’t updating the latest version of the record. They’re overwriting it.

This shows up more in back-office tools than public apps because the work is collaborative and record-based. Internal teams edit the same customers, orders, products, and requests repeatedly, often in short bursts. Public apps are more often “one user edits their own stuff,” while admin tools are “many users edit shared stuff.”

The damage is rarely dramatic in the moment, but it adds up fast:

  • A product price gets changed back to an old value right after a promo update.
  • A support agent’s internal note disappears, so the next agent repeats the same troubleshooting.
  • An order status flips backward (for example, “Shipped” back to “Packed”), triggering the wrong follow-up.
  • A customer phone number or address is replaced with outdated info.

Silent overwrites are painful because everyone thinks the system saved correctly. There’s no clear “something went wrong” moment, just confusion later when reports look off or a teammate asks, “Who changed this?”

Conflicts like this are normal. They’re a sign the tool is shared and useful, not that your team is doing something wrong. The goal isn’t to stop two people from editing. It’s to detect when the record changed while someone was editing, then handle that moment safely.

If you’re building an internal tool in a no-code platform like AppMaster, it’s worth planning for this early. Admin tools tend to grow quickly, and once teams depend on them, losing data “sometimes” turns into a constant source of mistrust.

Optimistic locking in plain English

When two people open the same record and both hit Save, you have concurrency. Each person started from an older snapshot, but only one can be “the latest” when the saves happen.

Without protection, the last save wins. That’s how you get silent overwrites: the second save quietly replaces the first person’s changes.

Optimistic locking is a simple rule: “I’ll save my changes only if the record is still in the same state as when I started editing.” If the record changed in the meantime, the save is rejected and the user sees a conflict.

This is different from pessimistic locking, which is closer to “I’m editing this, so nobody else can.” Pessimistic locking usually means hard locks, timeouts, and people getting blocked. It can be useful in rare cases (like moving money between accounts), but it’s often frustrating in busy admin tools where many small edits happen all day.

Optimistic locking is usually the better default because it keeps work flowing. People can edit in parallel, and the system only steps in when there’s a real collision.

It fits best when:

  • Conflicts are possible but not constant.
  • Edits are quick (a few fields, a short form).
  • Blocking others would slow the team down.
  • You can show a clear “someone updated this” message.
  • Your API can check a version (or timestamp) on every update.

What it prevents is the “quiet overwrite” problem. Instead of losing data, you get a clean stop: “This record has changed since you opened it.”

What it can’t do matters too. It won’t stop two people from making different (but valid) decisions based on the same old information, and it doesn’t magically merge changes for you. And if you skip the check on the server side, you haven’t really solved anything.

Common limits to keep in mind:

  • It won’t resolve conflicts automatically (you still need a choice).
  • It won’t help if users edit offline and sync later without checks.
  • It won’t fix bad permissions (someone can still edit what they shouldn’t).
  • It won’t catch conflicts if you only check on the client.

In practice, optimistic locking is just one extra value carried with the edit, plus a server-side “only update if it matches” rule. If you’re building an admin panel in AppMaster, that check typically lives in your business logic right where updates are executed.

Two common approaches: version column vs updated_at

To detect that a record changed while someone was editing it, you usually pick one of two signals: a version number or an updated_at timestamp.

Approach 1: Version column (incrementing integer)

Add a version field (usually an integer). When you load the edit form, you also load the current version. When you save, you send that same value back.

The update only succeeds if the stored version still matches what the user started with. If it matches, you update the record and increment version by 1. If it doesn’t match, you return a conflict instead of overwriting.

This is easy to reason about: version 12 means “this is the 12th change.” It also avoids time-related edge cases.

Approach 2: updated_at (timestamp compare)

Most tables already have an updated_at field. The idea is the same: read updated_at when the form opens, then include it with the save. The server only updates if updated_at is unchanged.

It can work well, but timestamps have gotchas. Different databases store different precision. Some round to seconds, which can miss rapid edits. If multiple systems write to the same database, clock drift and timezone handling can also create confusing edge cases.

A simple way to compare:

  • Version column: clearest behavior, portable across databases, no clock problems.
  • updated_at: often “free” because it already exists, but precision and clock handling can bite.

For most teams, a version column is the better primary signal. It’s explicit, predictable, and easy to reference in logs and support tickets.

If you’re building in AppMaster, this typically means adding an integer version field in the Data Designer and making sure your update logic checks it before saving. You can still keep updated_at for auditing, but let the version number decide whether an edit is safe to apply.

What to store and what to send with each edit

Optimistic locking only works if every edit carries a “last seen” marker from the moment the user opened the form. That marker can be a version number or an updated_at timestamp. Without it, the server can’t tell whether the record changed while the user was typing.

On the record itself, keep your normal business fields, plus one concurrency field the server controls. The minimum set looks like this:

  • id (stable identifier)
  • business fields (name, status, price, notes, etc.)
  • version (integer that increments on every successful update) or updated_at (server-written timestamp)

When the edit screen loads, the form must store the last-seen value of that concurrency field. Users shouldn’t edit it, so keep it as a hidden field or in the form state. Example: the API returns version: 12, and the form keeps 12 until Save.

When the user clicks Save, send two things: the changes and the last-seen marker. The simplest shape is to include it in the update request body, like id, changed fields, and expected_version (or expected_updated_at). If you’re building the UI in AppMaster, treat this like any other bound value: load it with the record, keep it unchanged, and submit it back with the update.

On the server, the update must be conditional. You only update if the expected marker matches what’s currently in the database. If it doesn’t match, don’t “merge” silently.

A conflict response should be clear and easy to handle in the UI. A practical conflict response includes:

  • HTTP status 409 Conflict
  • a short message like “This record was updated by someone else.”
  • the current server value (current_version or current_updated_at)
  • optionally, the current server record (so the UI can show what changed)

Example: Sam opens a Customer record at version 12. Priya saves a change, making it version 13. Sam later hits Save with expected_version: 12. The server returns 409 with the current record at version 13. Now the UI can prompt Sam to review the latest values instead of overwriting Priya’s edit.

Step-by-step: implement optimistic locking end to end

Avoid technical debt later
Create APIs and business logic visually, then regenerate clean code as requirements change.
Build backend

Optimistic locking mostly comes down to one rule: every edit must prove it’s based on the latest saved version of the record.

1) Add a concurrency field

Pick one field that changes on every write.

A dedicated integer version is easiest to reason about. Start at 1 and increment on each update. If you already have a reliable updated_at timestamp that always changes, you can use that instead, but make sure it updates on every write (including background jobs).

2) Send that value to the client on read

When the UI opens an edit screen, include the current version (or updated_at) in the response. Store it in the form state as a hidden value.

Think of it as a receipt that says, “I am editing what I last read.”

3) Require the value on update

On save, the client sends back the edited fields plus the last-seen concurrency value.

On the server, make the update conditional. In SQL terms, it is:

UPDATE tickets
SET status = $1,
    version = version + 1
WHERE id = $2
  AND version = $3;

If the update affects 1 row, the save succeeded. If it affects 0 rows, somebody else already changed the record.

4) Return the new value after success

After a successful save, return the updated record with the new version (or new updated_at). The client should replace the form state with what the server returns. This prevents “double saves” using an old version.

5) Treat conflicts as a normal outcome

When the conditional update fails, return a clear conflict response (often HTTP 409) that includes:

  • the current record as it exists now
  • the client’s attempted changes (or enough info to reconstruct them)
  • which fields differ (if you can compute that)

In AppMaster, this maps well to a PostgreSQL model field in Data Designer, a read endpoint that returns the version, and a Business Process that performs the conditional update and branches into either success or conflict handling.

UI patterns that handle conflicts without annoying users

Choose your deployment path
Deploy to AppMaster Cloud, AWS, Azure, Google Cloud, or export source code.
Deploy app

Optimistic locking is only half the job. The other half is what the person sees when their save is rejected because someone else changed the record.

Good conflict UI has two goals: stop silent overwrites, and help the user finish their task fast. Done well, it feels like a helpful guardrail, not a roadblock.

Pattern 1: The simple blocking dialog (fastest)

Use this when edits are small, and users can safely re-apply their changes after reloading.

Keep the message short and specific: “This record changed while you were editing. Reload to see the latest version.” Then give two clear actions:

  • Reload and continue (primary)
  • Copy my changes (optional but helpful)

“Copy my changes” can put the unsaved values on the clipboard or keep them in the form after reload, so people don’t have to remember what they typed.

This works well for single-field updates, toggles, status changes, or short notes. It’s also the easiest to implement in most builders, including AppMaster-based admin screens.

Pattern 2: “Review changes” (best for high-value records)

Use this when the record is important (pricing, permissions, payouts, account settings) or the form is long. Instead of a dead-end error, route the user to a conflict screen that compares:

  • “Your edits” (what they tried to save)
  • “Current values” (latest from the database)
  • “What changed since you opened it” (the conflicting fields)

Keep it focused. Don’t show every field if only three fields are in conflict.

For each conflicting field, offer simple choices:

  • Keep mine
  • Take theirs
  • Merge (only when it makes sense, like tags or notes)

After the user resolves conflicts, save again with the newest version value. If you support rich text or long notes, show a small diff view (added/removed text) so users can make a quick call.

When to allow a forced overwrite (and who can do it)

Sometimes a forced overwrite is needed, but it should be rare and controlled. If you add it, make it deliberate: require a short reason, log who did it, and limit it to roles like admins or supervisors.

For regular users, default to “Review changes.” Forced overwrite is most defensible when the user is the record owner, the record is low-risk, or the system is correcting bad data under supervision.

Example scenario: two teammates edit the same record

Two support agents, Maya and Jordan, are working in the same admin tool. They both open the same customer profile to update the customer status and add notes after separate calls.

Timeline (with optimistic locking enabled using either a version field or an updated_at check):

  • 10:02 - Maya opens Customer #4821. The form loads Status = "Needs follow-up", Notes = "Called yesterday" and Version = 7.
  • 10:03 - Jordan opens the same customer. He sees the same data, also Version = 7.
  • 10:05 - Maya changes Status to "Resolved" and adds a note: "Issue fixed, confirmed by customer." She clicks Save.
  • 10:05 - The server updates the record, increments Version to 8 (or updates updated_at), and stores an audit entry: who changed what and when.
  • 10:09 - Jordan types a different note: "Customer asked for a receipt" and clicks Save.

Without a concurrency check, Jordan’s save could silently overwrite Maya’s status and note, depending on how the update is built. With optimistic locking, the server rejects Jordan’s update because he’s trying to save Version = 7 while the record is already at Version = 8.

Jordan sees a clear conflict message. The UI shows what happened and gives him a safe next step:

  • Reload the latest record (discard my edits)
  • Apply my changes on top of the latest record (recommended when possible)
  • Review differences (show "Mine" vs "Latest") and choose what to keep

A simple screen can show:

  • “This customer was updated by Maya at 10:05”
  • The fields that changed (Status and Notes)
  • A preview of Jordan’s unsaved note, so he can copy it or re-apply it

Jordan chooses “Review differences,” keeps Maya’s Status = "Resolved", and appends his note to the existing notes. He saves again, this time using Version = 8, and the update succeeds (now Version = 9).

End state: no data loss, no guessing who overwrote whom, and a clean audit trail that shows Maya’s status change and both notes as separate, traceable edits. In a tool built with AppMaster, this maps neatly to one check on update plus a small conflict-resolution dialog in the admin UI.

Common mistakes that still cause data loss

Fix one workflow first
Start with one high-collision screen like tickets or orders and expand the pattern.
Get started

Most “optimistic locking” bugs aren’t about the idea. They happen in the handoff between the UI, the API, and the database. If any one layer forgets the rules, you can still get silent overwrites.

A classic mistake is collecting the version (or timestamp) when the edit screen loads, but not sending it back on save. This often happens when a form is reused across pages and the hidden field gets dropped, or when an API client only sends “changed” fields.

Another common trap is doing conflict checks only in the browser. The user might see a warning, but if the server accepts the update anyway, a different client (or a retry) can overwrite data. The server must be the final gatekeeper.

Patterns that cause the most data loss:

  • Missing the concurrency token in the save request (version, updated_at, or ETag), so the server has nothing to compare.
  • Accepting updates without an atomic condition, such as updating by id only instead of “id + version.”
  • Using updated_at with low precision (for example, seconds). Two edits in the same second can look identical.
  • Replacing big fields (notes, descriptions) or whole arrays (tags, line items) without showing what changed.
  • Treating any conflict as “just retry,” which can re-apply stale values on top of newer data.

A concrete example: two support leads open the same customer record. One adds a long internal note, the other changes the status and saves. If your save overwrites the full record payload, the status change can accidentally erase the note.

When a conflict does happen, teams still lose data if the API response is too thin. Don’t just return “409 Conflict.” Return enough for a human to fix it:

  • The current server version (or updated_at)
  • The latest server values for the fields involved
  • A clear list of fields that differ (even simple names)
  • Who changed it and when (if you track it)

If you’re implementing this in AppMaster, apply the same discipline: keep the version in the UI state, send it with the update, and enforce the check inside the backend logic before writing to PostgreSQL.

Quick checks before you ship

One tool for web and mobile
Generate web and native mobile apps that stay consistent across teams.
Try now

Before you roll this out, do a quick pass for the failure modes that create “it saved fine” while quietly overwriting someone else’s work.

Data and API checks

Make sure the record carries a concurrency token end to end. That token can be a version integer or an updated_at timestamp, but it has to be treated as part of the record, not optional metadata.

  • Reads include the token (and the UI stores it with the form state, not just on the screen).
  • Every update sends back the last-seen token, and the server verifies it before writing.
  • On success, the server returns the new token so the UI stays in sync.
  • Bulk edits and inline edits follow the same rule, not a special shortcut.
  • Background jobs that edit the same rows also check the token (or they’ll create conflicts that look random).

If you’re building this in AppMaster, double-check that the Data Designer field exists (version or updated_at), and that your Business Process update flow compares it before running the actual update.

UI checks

A conflict is only “safe” if the next step is obvious.

When the server rejects an update, show a clear message like: “This record changed since you opened it.” Then offer one safe action first: reload the latest data. If possible, add a “reload and reapply” path that keeps the user’s unsaved inputs and reapplies them to the refreshed record, so a small fix doesn’t become a retyping session.

If your team truly needs it, add a controlled “force save” option. Gate it by role, confirm it, and log who forced it and what changed. That keeps emergencies possible without making data loss the default.

Next steps: add locking to one workflow and expand

Start small. Pick one admin screen where people regularly bump into each other, and add optimistic locking there first. High-collision areas are usually tickets, orders, pricing, and inventory. If you make conflicts safe on one busy screen, you’ll quickly see the pattern you can repeat elsewhere.

Choose your default conflict behavior up front, because it shapes both backend logic and UI:

  • Block-and-reload: stop the save, reload the latest record, and ask the user to reapply their change.
  • Review-and-merge: show “your changes” vs “latest changes” and let the user decide what to keep.

Block-and-reload is faster to build and works well when edits are short (status changes, assignment, small notes). Review-and-merge is worth it when records are long or high-stakes (pricing tables, multi-field order edits).

Then implement and test one complete flow before you expand:

  • Pick one screen and list the fields users edit most.
  • Add a version (or updated_at) value to the form payload and require it on save.
  • Make the update conditional in the database write (only update if the version matches).
  • Design the conflict message and next action (reload, copy my text, open compare view).
  • Test with two browsers: save in tab A, then try saving stale data in tab B.

Add lightweight logging for conflicts. Even a simple “conflict happened” event with record type, screen name, and user role helps you spot hotspots.

If you build admin tools with AppMaster (appmaster.io), the main pieces map cleanly: model a version field in the Data Designer, enforce conditional updates in Business Processes, and add a small conflict dialog in the UI builder. Once the first workflow is stable, repeat the same pattern screen by screen, and keep the conflict UI consistent so users learn it once and trust it everywhere.

FAQ

What is a “silent overwrite” and why does it happen?

A silent overwrite happens when two people edit the same record from different tabs or sessions, and the last save replaces the earlier changes without any warning. The risky part is that both users see a “successful save,” so the missing edits are only noticed later.

What does optimistic locking do in plain terms?

Optimistic locking means the app only saves your changes if the record hasn’t changed since you opened it. If someone else saved first, your save is rejected with a conflict so you can review the latest data instead of overwriting it.

Why not just lock the record so nobody else can edit it?

Pessimistic locking blocks others from editing while you’re working, which often creates waiting, timeouts, and “who locked this?” moments in admin teams. Optimistic locking usually fits admin panels better because people can work in parallel and only deal with collisions when they actually happen.

Should I use a version number or `updated_at` for conflict checks?

A version column is usually the simplest and most predictable option because it avoids timestamp precision and clock issues. An updated_at check can work, but it can miss rapid edits if the timestamp is stored with low precision or handled inconsistently across systems.

What data has to be included to make optimistic locking work?

You need a server-controlled concurrency token on the record, typically version (integer) or updated_at (timestamp). The client must read it when the form opens, keep it unchanged while the user edits, and send it back on save as the “expected” value.

Why must the version check be done on the server, not just in the UI?

Because the client can’t be trusted to protect shared data. The server must enforce a conditional update like “update where id matches and version matches,” otherwise another client, retry, or background job can still overwrite changes silently.

What should the user see when a conflict happens?

A good default is a blocking message that says the record changed and offers a single safe next step: reload the latest version. If people typed something long, keep their unsaved input so they can reapply it after reload rather than retyping from memory.

What should the API return on a conflict to help the UI recover?

Return a clear conflict response (often a 409) plus enough context to recover: the current server version and the latest server values. If you can, include who updated it and when, so the user understands why their save was rejected and what changed.

What are the most common mistakes that still lead to data loss?

Watch for missing tokens on save, updates that only filter by id instead of id + version, and timestamp checks with low precision. Another common issue is replacing the whole record payload (like notes or arrays) instead of updating only intended fields, which increases the chance of wiping someone else’s edits.

How do I implement this in AppMaster without custom coding?

In AppMaster, add a version field in the Data Designer and include it in the record your UI reads into the form state. Then enforce a conditional update inside your Business Process so the write only succeeds when the expected version matches, and handle the conflict branch in the UI with a reload/review flow.

Easy to start
Create something amazing

Experiment with AppMaster with free plan.
When you will be ready you can choose the proper subscription.

Get Started