Status: Experimental feature plugin
System-wide presence and awareness for WordPress.
Important
Built for the lowest common denominator of environments. No object cache, no WebSockets, no extra services β a dedicated table with a TTL is the only moving part. Anything a managed host offers on top is a bonus, never a dependency.
WordPress has no way to know who is logged in, what screen they are on, or which posts are being edited β without writing to shared tables like wp_postmeta or wp_options. High-frequency writes to those tables invalidate caches site-wide (#64696). This plugin uses a dedicated wp_presence table with a 150-second TTL to provide that awareness with zero cache side effects.
"This idea of presence I think is really cool and seeing where people are... you log into your WordPress, I see oh Matias is moderating some comments, Lynn is on the dashboard maybe reading some news... that idea of like you log in and you can kind of see the neighborhood of like who else is also there." β Matt Mullenweg, WordPress 7.0 planning session
npm install
npx wp-env startThen open localhost:8888/wp-admin/ (admin / password).
No install needed β launch a scratch site straight from main.
| 5 users | 40 users | |
|---|---|---|
| Single site | ||
| Multisite |
- Browser sends
presence-pingvia Heartbeat - Server upserts into
wp_presence - Server reads the room and returns entries in the heartbeat response
- Client diffs a signature of user IDs and swaps HTML when content changes
- Client-side interval re-evaluates idle state every 5s between heartbeat ticks
| Pattern | Example |
|---|---|
admin/online |
All admin pages |
postType/{type}:{id} |
postType/post:42 |
Post types opt in via add_post_type_support( 'post', 'presence' ).
Rooms carry no producer namespace of their own β this plugin's own writers are told apart from anyone else's entries in the same room by a client_id prefix instead. user-{user_id} (admin/online room, from includes/heartbeat.php and includes/lifecycle.php), editor-{user_id} (post rooms, from includes/heartbeat.php and includes/post-lock-bridge.php) and cli-{user_id} (any room, from includes/cli/class-wp-presence-cli-command.php, the default when wp presence set is given no client ID) are reserved this way; a row using any of them belongs to this plugin and has the state shape its writer expects.
Anything else sharing a room β another plugin relaying awareness from an external source, a REST client β must prefix its own client_id so it can't collide with those rows or be mistaken for one. A plugin that backs the block editor's awareness with this table takes a prefix of its own, so sync- (sync-storage) and gse- (gutenberg-sync-engines) are both spoken for. Pass that prefix as the third argument to wp_get_presence() to read back only your own rows: wp_get_presence( $room, $timeout, 'gse-' ).
A leading _ is reserved for this plugin's own bookkeeping rows, which are not participants. _collab holds a post room's last observed editor count, the state the collaboration actions below fire their edges from. wp_get_presence() and the REST collection leave those rows out, and the REST write and delete routes reject a reserved client_id.
The editor- prefix is load-bearing rather than cosmetic: includes/heartbeat.php counts the editors in a post room with str_starts_with( $entry->client_id, 'editor-' ), so a colliding prefix inflates that count.
Functions, return shapes, and network variants
The following public functions are part of the stable public API contract. All other helper functions in includes/functions.php and includes/network-functions.php (such as wp_get_active_rooms(), wp_get_presence_summary(), etc.) are marked @access private, are intended for internal plugin use only, and may change or be removed without notice.
// Read all presence entries in a room, or only those whose client_id starts
// with $client_prefix. The prefix is matched literally, in the query.
$entries = wp_get_presence( $room, $timeout = WP_PRESENCE_DEFAULT_TTL, $client_prefix = '' );
// Upsert a client's presence state. Atomic via INSERT β¦ ON DUPLICATE KEY UPDATE.
// $date_gmt ('Y-m-d H:i:s') lets a caller relaying awareness on behalf of
// other clients preserve their timestamps instead of stamping every relayed
// row with its own clock; a value in the future is clamped to now, and a
// value that isn't a real calendar date is rejected (returns false). Passing
// it also skips the internal check that otherwise leaves an unchanged row
// untouched, since a relay backdating a departed collaborator needs that
// write to land. Defaults to now.
wp_set_presence( $room, $client_id, $state, $user_id = 0, $date_gmt = null );
// Remove a single client from a room.
wp_remove_presence( $room, $client_id );
// Remove all presence entries for a user across all rooms.
wp_remove_user_presence( $user_id );
// Check whether a user can access a room (requires edit_posts).
wp_can_access_presence_room( $room, $user_id = 0 );
// Return the canonical room string for a post, or false if the post type
// does not support presence.
$room = wp_presence_post_room( $post );
// Whether this site records presence at all.
wp_presence_recording_enabled();
// Whether presence can be used here: the table exists and recording is on.
wp_presence_is_available();If you build on this plugin, check wp_presence_is_available() before you depend on it. The function_exists() guard covers the plugin not being loaded:
if ( function_exists( 'wp_presence_is_available' ) && wp_presence_is_available() ) {
// Read and write presence.
}Without that check, a site with no table or with recording off still accepts your calls: wp_set_presence() returns false, and wp_get_presence() returns an empty array that reads the same as an empty room.
Each entry object returned by wp_get_presence() has:
| Field | Type | Notes |
|---|---|---|
room |
string |
The room the entry belongs to. |
client_id |
string |
A varchar column β opaque, and not guaranteed numeric even when it looks like one. See Client IDs. |
user_id |
string |
"0" for an entry with no signed-in user. Every column comes back as a string, so cast before a strict comparison. |
data |
array |
Decoded from the stored JSON; an empty array if that JSON failed to decode. |
date_gmt |
string |
A MySQL datetime string in UTC (e.g. 2024-01-01 12:00:00), not a Unix timestamp. Convert with strtotime( $entry->date_gmt . ' UTC' ). See below for how far behind a live client it can sit. |
date_gmt is not rewritten on every ping. An unchanged row is left alone until it is 30 seconds old, however high the TTL goes, and that is on top of the gap the client leaves between pings, so both have to fit inside any liveness window you read off date_gmt yourself. Entries from wp_get_presence() are already filtered on the TTL, so the window only matters if you are working out a tighter one. Passing an explicit $date_gmt to wp_set_presence() writes every time.
Multisite only, from includes/network-functions.php. Returns false outside multisite.
// Whether this network assembles its sites' rows into the network-wide view.
wp_presence_network_aggregation_enabled();A post type opts in to per-post presence rooms by declaring presence support. post and page are registered by the plugin; any other post type must opt in itself, either during registration or afterwards:
// During registration:
register_post_type( 'my-post-type', array(
'supports' => array( 'title', 'editor', 'presence' ),
) );
// Or afterwards, on a post type someone else registered:
add_post_type_support( 'my-post-type', 'presence' );Without support, wp_presence_post_room() returns false for that post type and no per-post room is created.
Filters and actions
Filters the presence TTL (time-to-live) in seconds used for all queries and cleanup. Default: 150.
Values under 120 drop a tab that is still open and still pinging, since that is the Heartbeat interval core gives an unfocused or five-minute-idle tab.
add_filter( 'wp_presence_default_ttl', function( $timeout ) {
return 300; // Override TTL to 5 minutes.
} );Or define the constant before the plugin loads:
define( 'WP_PRESENCE_DEFAULT_TTL', 300 );Filters the key identifying the current admin screen for stale-screen detection. Core screens (Settings, post.php, term, user, comment) resolve their own keys; $key is '' on any screen without coverage. Return a non-empty string to opt a custom screen in.
add_filter( 'wp_presence_current_screen_key', function( $key, $screen ) {
if ( 'toplevel_page_my-plugin' === $screen->id ) {
return 'options/my-plugin-settings';
}
return $key; // Leave other screens untouched.
}, 10, 2 );Keys follow the plugin's slash-separated room convention and are truncated to 191 characters (WP_PRESENCE_SCREEN_KEY_LIMIT). Use the same key when bumping the revision from JS via wp.presence.markScreenStale().
Filters whether presence is recorded on this site. Default: the Presence checkbox on Settings > General, which is on for a new install. Return false and nothing further is written; every surface empties within one TTL as the rows already stored expire, so there is nothing else to clear.
add_filter( 'wp_presence_recording_enabled', '__return_false' );Because the checkbox is only the filter's default, a filter always has the last word over whatever an administrator has chosen.
On multisite, wp_presence_network_recording_enabled does the same for every site at once, defaulting to the Presence checkbox on Network Admin > Settings. It is consulted only once the site-level filter has allowed recording, so either switch turning off wins and neither can turn the other back on.
Filters whether a network assembles its sites' rows into the network-wide view behind Network Admin. Default: true below wp_is_large_network(), which answers write concentration rather than policy. Independent of recording: a network can go on recording site by site and still switch the aggregate off.
add_filter( 'wp_presence_network_aggregation_enabled', '__return_false' );Fires after an admin screen revision has been bumped. Useful for triggering custom sync or WebSocket integrations.
add_action( 'wp_presence_screen_revision_bumped', function( $screen_key, $revision, $actor_id ) {
// Custom sync logic
}, 10, 3 );Fires when collaboration starts in a room (transition from 1 to 2+ editors). Only entries whose client_id begins with editor- count toward the transition, while $entries is every client entry in the room, reserved bookkeeping rows excluded. The previous count is held in the room's _collab row, which ages out on the presence TTL, so once those entries have gone the next pair reads as a fresh start.
add_action( 'wp_presence_collaboration_started', function( $room, $entries ) {
// Announce room active or update integration state
}, 10, 2 );Fires when collaboration ends in a room (transition from 2+ to exactly 1 editor). The check runs on an editor heartbeat tick, so if every editor leaves at once there is nobody left to tick and the hook does not fire; the _collab row ages out on the presence TTL and the room resets quietly.
add_action( 'wp_presence_collaboration_ended', function( $room, $entries ) {
// Announce room inactive or update integration state
}, 10, 2 );Fired through wp.hooks, not PHP. presence-ping.js is the only thing that computes these, so a consumer has to listen rather than poll for them.
Fires once, synchronously, before Heartbeat's first tick. Only on a post-edit screen for a post type with presence support β lets a listener tell "presence-api isn't here" apart from "here, no tick yet."
wp.hooks.addAction( 'presence-api.watchingRoom', 'my-plugin', ( room ) => {
// room is 'postType/{type}:{id}'
} );Fires on the same 1-to-2+ edge as wp_presence_collaboration_started, not on every tick. count is the room's editor count at that moment and includes you, so it is 2 or more. A third editor joining later does not fire anything, and does not update a count a listener kept, since the edge has already been crossed. Anything that needs a live number should read the room instead.
wp.hooks.addAction( 'presence-api.collaborationStarted', 'my-plugin', ( room, count ) => {
// Someone besides you is now in the room.
} );Fires on the 2+-to-1 edge, mirroring wp_presence_collaboration_ended. count is 1: you. If every editor leaves at once, nobody is left to tick, so this does not fire and the room resets on the TTL.
wp.hooks.addAction( 'presence-api.collaborationEnded', 'my-plugin', ( room, count ) => {
// You are alone in the room again.
} );All endpoints require edit_posts. Responses include Cache-Control: no-store.
Endpoints
| Method | Path | Description |
|---|---|---|
GET |
/wp-presence/v1/presence |
List entries in a room |
POST |
/wp-presence/v1/presence |
Upsert a presence entry |
DELETE |
/wp-presence/v1/presence |
Remove a presence entry |
GET |
/wp-presence/v1/presence/rooms |
List active rooms |
Multisite only, and gated on manage_network rather than edit_posts.
| Method | Path | Description |
|---|---|---|
GET |
/wp-presence/v1/presence/network |
List sites with users online, busiest first |
GET |
/wp-presence/v1/presence/network/<blog_id> |
One site's users online |
The collection accepts page and per_page (default 50, max 100), counting sites in X-WP-Total and X-WP-TotalPages and the network headcount in X-WP-Presence-Users-Online. Both routes accept users_per_site to cap the users named per site (default 0, every user); each site's user_count stays its real total. A site nobody is on answers with an empty user list, so only an unknown blog_id is a 404.
Commands
wp presence list # List all active presence entries
wp presence summary # Summary grouped by room
wp presence set # Manually upsert an entry
wp presence cleanup # Delete expired entries immediately
wp presence network # Network-wide summary (multisite only)
wp presence recording # Read or set the recording switch
wp presence recording get
wp presence recording set off
wp presence recording set off --network # Multisite only
Real-time awareness inside the editor β cursors, selections, who's editing which block β is not this plugin's job. It belongs to whichever plugin implements the block editor's collaboration storage. This table is where that plugin can keep the awareness half.
Gutenberg's __unstable_wp_sync_storage filter takes a WP_Sync_Storage, a single interface covering awareness and the CRDT update log together, so a plugin on that filter supplies both. WordPress/gutenberg#83165 asks for the two to be separable. Two plugins have used this table for the awareness half: sync-storage, which implemented the filter directly, and gutenberg-sync-engines, which splits awareness off behind its own wp_sync_awareness_backend filter and points that at wp_get_presence(), wp_set_presence() and wp_remove_presence(). CRDT document updates stay in the editor plugin's own table either way.
No room mapping sits between the two sides: both use postType/{type}:{id}, the grammar Gutenberg's WP_Sync_Config::parse_room() defines and wp_presence_post_room() already returns. Inside that shared room the client_id prefix keeps the rows apart. An editor plugin writes and reads only its own (sync-{id}, gse-{id}), leaving this plugin's editor-{user_id} rows untouched.
That leaves the split: awareness and cursors inside the editor go through that plugin; room membership everywhere else in wp-admin β plus the post-lock bridge below β stays this plugin's. Where an editor plugin uses this table, someone editing a post also shows up in Who's Online and the post list.
The JS actions above exist for that same relationship. A consumer like Gutenberg's sync poll loop (presence-api#444) can wait for presence-api.collaborationStarted instead of polling to find out whether anyone else is in the room.
Creates presence entries alongside _edit_lock postmeta when a post lock is refreshed via Heartbeat. Both systems coexist.
All features require edit_posts.
Warns users when an admin screen they are viewing has been modified by someone else.
Classic admin screens that save via POST and redirect (like Settings or post.php) are covered automatically.
Custom JS-driven screens (like Gutenberg settings panels or custom plugin screens) can opt-in by bumping the screen revision after a successful background save:
// After a successful REST or AJAX save:
if (window.wp?.presence?.markScreenStale) {
wp.presence.markScreenStale('options/my-custom-plugin-settings');
}For a screen to be watched in the first place, it needs a screen key β core screens resolve their own, and custom screens supply one via the wp_presence_current_screen_key filter.
Sponsored by the Core team. Updates posted on make.wordpress.org/core with the tag #presence-api.
Questions and bug reports: GitHub Issues.
Discussion: #feature-presence-api on WordPress Slack