How _edit_lock Works as a Pessimistic Lock Row in wp_postmeta — and Why Two Editors Can Still Collide After a Browser Crash

Two editors open the same post. One browser crashes. The other editor keeps working, saves, and then the first editor’s tab — restored from session — saves over the top. The lock was supposed to prevent this. It did not, because _edit_lock is not a lock in the mutual-exclusion sense. It is a timestamped freshness heuristic stored as ordinary postmeta, and the only thing standing between two writers is whether that timestamp is recent enough.

Symptom

The observable failure is not a database error or a REST 409. It is a silent overwrite. Editor A’s browser process dies at 10:04. Editor B opens the post at 10:06, sees no lock notice, edits, and saves. Editor A’s tab is restored at 10:09, the Heartbeat response no longer reports a lock error, and the editor saves. The post now contains A’s content. B’s changes are gone.

If you were watching the network panel, you would have seen the Heartbeat request from A’s tab stop at 10:04 and resume at 10:09. Between those points, wp_check_post_lock() returned false for B, and B was admitted legitimately by the read path.

Trace: the write path

The lock is written by wp_set_post_lock(). The function is short enough to read in full:

function wp_set_post_lock( $post ) {
    $post = get_post( $post );
    if ( ! $post ) {
        return false;
    }
    $user_id = get_current_user_id();
    if ( 0 === $user_id ) {
        return false;
    }
    $now = time();
    $lock = "$now:$user_id";
    update_post_meta( $post->ID, '_edit_lock', $lock );
    return array( $now, $user_id );
}

Two things matter here. First, the value is a single string formatted as timestamp:user_id. Second, the write is an unconditional update_post_meta() call. There is no compare-and-swap, no row-level constraint, no session token. Any caller with the post ID and a current user can overwrite the row. The function returns false only when the post does not exist or there is no current user (wp_set_post_lock()).

Because the value lives in wp_postmeta, it is visible to any SQL client and any WP-CLI session. That is the diagnostic surface:

wp post meta get 1234 _edit_lock
# 1729143840:7

Or directly:

SELECT meta_value
FROM wp_postmeta
WHERE post_id = 1234
  AND meta_key = '_edit_lock';

The first segment is a Unix timestamp. The second is a user ID. Nothing else is stored — no client identifier, no tab identifier, no heartbeat sequence number.

Trace: the read path

The lock is read by wp_check_post_lock(). The relevant portion of the source:

$lock = get_post_meta( $post->ID, '_edit_lock', true );
if ( ! $lock ) {
    return false;
}
$lock = explode( ':', $lock );
$time = $lock[0];
$user = isset( $lock[1] ) ? (int) $lock[1] : (int) get_post_meta( $post->ID, '_edit_last', true );
if ( ! get_userdata( $user ) ) {
    return false;
}
$time_window = apply_filters( 'wp_check_post_lock_window', 150 );
if ( $time && $time > time() - $time_window && get_current_user_id() !== $user ) {
    return $user;
}
return false;

Four conditions must all hold for the function to return a user ID:

  1. The _edit_lock meta value exists.
  2. The user ID parsed from the value (or falling back to _edit_last) resolves to a real user.
  3. The stored timestamp is within the last 150 seconds, filtered by wp_check_post_lock_window.
  4. The current user is not the lock holder.

If any condition fails, the function returns false — meaning “not locked” as far as the caller is concerned. The documentation states the return is false when the post does not exist, is not locked, the user with the lock does not exist, or the post is locked by the current user (wp_check_post_lock()).

The 150-second window is the entire enforcement mechanism. There is no other check. A lock older than 150 seconds is treated as absent, regardless of whether the editor’s browser is still open.

Trace: the refresh path

What keeps a live editor’s timestamp fresh is wp_refresh_post_lock(), which runs on the Heartbeat API. The function checks the lock and either reports a conflict or writes a new one:

$user_id = wp_check_post_lock( $post_id );
$user = get_userdata( $user_id );
if ( $user ) {
    $error = array(
        'name' => $user->display_name,
        'text' => sprintf( __( '%s has taken over and is currently editing.' ), $user->display_name ),
    );
    // ...
    $send['lock_error'] = $error;
} else {
    $new_lock = wp_set_post_lock( $post_id );
    if ( $new_lock ) {
        $send['new_lock'] = implode( ':', $new_lock );
    }
}
$response['wp-refresh-post-lock'] = $send;

Two details are worth noting. First, the function returns early without touching the lock if the current user lacks the edit_post capability for that post (wp_refresh_post_lock()). Second, when another user holds the lock, the response carries a lock_error with that user’s display name — this is the string the editor UI renders as the takeover notice.

When no other user holds the lock, the function writes a fresh timestamp via wp_set_post_lock() and returns it in the Heartbeat response. This is the mechanism that distinguishes a live editor from a dead one: the live editor’s tab keeps sending Heartbeat requests, and each one refreshes the timestamp. The dead editor’s tab stops, and the timestamp ages out.

Root cause: freshness, not exclusion

The collision after a browser crash is not a bug in the lock. It is the lock working as designed. The design is a freshness heuristic:

  • The write path does not prevent a second writer. It overwrites.
  • The read path does not enforce exclusivity. It checks whether the stored timestamp is recent.
  • The refresh path is the only thing that keeps a live editor’s timestamp recent.

When a browser crashes, the refresh path stops. The row remains in wp_postmeta with its last timestamp. For up to 150 seconds, wp_check_post_lock() still returns the crashed editor’s user ID, and a second editor is warned. After 150 seconds, the function returns false, and the second editor is admitted.

The first editor’s tab, if restored, resumes Heartbeat. At that point wp_check_post_lock() may return the second editor’s user ID, and the first editor sees the takeover notice. But the notice is informational. It does not prevent the first editor from saving. The save path does not re-check the lock in a way that blocks the write.

So the practical collision is not “the lock failed.” It is “the lock expired while the first editor still believed they held it.” The second editor was admitted legitimately by the read path. The first editor’s next Heartbeat is what surfaces the takeover — after the fact.

What the lock does not do

It is worth being explicit about the boundaries, because the failure mode is easy to misread:

  • It does not prevent concurrent writes at the database level. There is no unique constraint on _edit_lock, and update_post_meta() will happily overwrite an existing row.
  • It does not track sessions or tabs. The value is timestamp:user_id. Two tabs for the same user share the same lock identity.
  • It does not distinguish a crash from a slow save. Both leave a timestamp that ages out on the same schedule.
  • It does not block a save. The editor UI may show a notice, but the REST or admin-ajax save path does not reject the request based on lock state.

The lock is advisory. It is a coordination hint rendered in the editor UI, backed by a timestamped postmeta row and a 150-second window.

Fix: what you can actually tune

If your team sees frequent post-crash collisions, the tuning point is the window filter:

add_filter( 'wp_check_post_lock_window', function( $window ) {
    return 300; // seconds
} );

Widening the window trades collisions for longer lockouts after genuine crashes. A 300-second window means a crashed editor’s lock persists for five minutes, during which a second editor is warned and cannot take over without an explicit action. That is the trade-off, and it should be stated as one rather than presented as a fix.

Narrowing the window does the opposite: faster recovery after a crash, more frequent collisions if Heartbeat is delayed. If your Heartbeat interval is longer than the window, live editors will appear to have stale locks. That is a configuration mismatch, not a lock failure.

For self-maintained installs, the lock row is also directly clearable:

wp post meta delete 1234 _edit_lock

This is safe when you have confirmed the lock holder is not actively editing. It is not safe as a routine operation, because it removes the only signal the editor UI has that another user is present.

Verification: a diagnostic sequence

When a collision is reported, the useful sequence is:

  1. Read the row. wp post meta get <post_id> _edit_lock. Note the timestamp and user ID.
  2. Compare the timestamp to now. If now - timestamp > 150 (or your filtered window), the lock is already inert. The collision happened because the window lapsed, not because the lock was ignored.
  3. Resolve the user ID. wp user get <user_id>. If the user no longer exists, wp_check_post_lock() returns false regardless of timestamp — the lock is treated as absent.
  4. Check the window filter. If a plugin or theme has modified wp_check_post_lock_window, the effective window is not 150 seconds. Grep for the filter name in wp-content.
  5. Check the Heartbeat response. In the browser DevTools Network panel, filter for admin-ajax.php with action=heartbeat. The response body contains a wp-refresh-post-lock key. If it contains lock_error, another user holds the lock. If it contains new_lock, the current user just refreshed it.

That sequence distinguishes the three failure modes: a lock that expired (timestamp too old), a lock that was ignored (user ID missing), and a lock that was never written (no row).

Why the row lives in wp_postmeta

The choice of wp_postmeta as storage has a side effect that matters on sites with a persistent object cache. Every wp_set_post_lock() call is an update_post_meta() call, which invalidates the post’s cache entry. On a busy editorial site, Heartbeat-driven lock refreshes can generate a steady stream of cache invalidations for posts that are not actually being saved.

The Presence API feature plugin addresses this by moving post locks out of postmeta and into a presence table, so that refreshing a lock no longer invalidates cached post queries on sites with a persistent object cache (Make WordPress Core: Presence API). The same source notes that locks keep working when presence recording is switched off. This is a structural change to where the lock lives, not a change to the freshness-window behavior that causes the crash collision.

It is worth being precise: the Presence API is described in that source as a feature plugin, not as shipped core behavior. The _edit_lock postmeta row is what current core uses.

Related operational notes

The Heartbeat-driven lock refresh has been reported to keep auth-check requests firing on post edit screens even when wp_auth_check_load is removed. In a 2014 trac mailing list thread, a reporter described the auth-check still firing from a combination of post.js and heartbeat.js, likely driven by the refresh-post-lock path (wp-trac mailing list, February 2014). That report is old and should not be treated as a description of current behavior, but it illustrates that the lock refresh is entangled with other Heartbeat-driven admin behavior.

If you are debugging a new install that reports “nothing found” on the front end while the editor shows content, that is a different failure mode with a different diagnostic path. The relevant sequence is covered in What to Fix First When a New WordPress Site Says Nothing Found.

FAQ

Does the lock prevent two editors from saving at the same time?
No. The lock is advisory. The save path does not reject a request based on lock state. The editor UI may show a notice, but the write proceeds.

What is the default lock window?
150 seconds, applied via the wp_check_post_lock_window filter. The default is stated in the wp_check_post_lock() source.

Why does the lock persist after a browser crash?
Because the row is written to wp_postmeta and is only removed or refreshed by a subsequent request. A crashed browser sends no further requests, so the row remains until the window lapses or another process clears it.

Can I clear a stale lock manually?
Yes, with wp post meta delete <post_id> _edit_lock. Confirm the lock holder is not actively editing first, because deleting the row removes the only signal the editor UI has that another user is present.

Does the lock work across multiple tabs for the same user?
The lock identity is the user ID, not the tab. Two tabs for the same user share the same lock state, and wp_check_post_lock() returns false when the current user is the lock holder.

What happens if the lock holder’s user account is deleted?
wp_check_post_lock() returns false when get_userdata() fails for the parsed user ID. The lock is treated as absent regardless of the timestamp.

Is the lock stored anywhere else?
In current core, no. The Presence API feature plugin moves locks into a presence table, but that is not shipped core behavior as of the retrieved source.