Docket Cache: A WordPress Object Cache Without Redis
I wrote Docket Cache because the usual advice for a slow WordPress site, "add Redis", is useless to anyone on cheap shared hosting. Those plans rarely offer Redis and give you no way to install it. What they usually do have is PHP with OPcache switched on, and that is enough to build a persistent object cache on. I have maintained the plugin since 2020. Here is how it works inside, what it changed on a test site, and when I would still tell you to use Redis instead.
What the WordPress object cache does
WordPress wraps most of its database reads in wp_cache_get() and wp_cache_set(). Options, posts, post meta, terms and the results of WP_Query all go through it. So the second time a request needs the same row, it comes from memory instead of MySQL.
The problem is that the built-in implementation is a PHP array. It lives for one request and is thrown away when the page has been sent. The next visitor starts from zero and runs the same queries again. WordPress lets you replace it with a drop-in file, wp-content/object-cache.php, and that is where every persistent object cache connects.
Why Redis and Memcached are not always an option
Redis and Memcached are the standard answers, and on a server you control they are good ones. Both are separate daemons, though. Someone has to install them, run them and secure them. On a low-cost shared plan you get a PHP account and a database, nothing more. Most file-based cache plugins fill that gap by storing entries with serialize() and reading them back with unserialize(). That means reading a file from disk and rebuilding the data on every cache hit.
The idea: cache entries as PHP files
Docket Cache writes each cache entry as a small PHP file that returns an array. Reading the entry is just an include. Because it is PHP source, OPcache compiles it once and keeps the result in shared memory, so later reads skip both the disk and the parsing. This is what one entry looks like after wp cache set greeting 'hello world' demo 3600:
<?php
return array (
'timestamp' => 1792262542,
'site_id' => 1,
'group' => 'demo',
'key' => 'greeting',
'type' => 'string',
'sv' => 3,
'timeout' => 1792266142,
'data' => 'hello world',
);
/*@DOCKET_CACHE_EOF*/How keys map to files
The file name is the first 12 hex characters of the MD5 of the group, a hyphen, and the first 12 of the MD5 of the key. Keys that are not plain strings or numbers are serialised before hashing. You can predict the name yourself:
$ php -r 'echo substr(md5("demo"),0,12), "-", substr(md5("greeting"),0,12), ".php\n";'
fe01ce2a7fba-699e0311097c.phpThat is the file the plugin wrote in wp-content/cache/docket-cache/. Twelve characters leave a small chance of a collision, so the real key and group are stored inside the file and compared on every read. A mismatch is treated as a cache miss. On multisite the blog prefix is added to keys outside the global groups, and an optional setting spreads files over subdirectories built from the hash characters.
Writing and reading safely
Values are turned into PHP code with var_export(), falling back to an exporter based on Symfony's VarExporter for data that holds class instances. The code goes to a temporary dump_ file in the same directory, which is then renamed over the old one, so a reader never sees half a file. After the rename the plugin asks OPcache to compile the new file. Reads take a shared flock() before the include. If a cached object belongs to a class that has not been loaded yet, the generated file checks class_exists() and returns false, which WordPress sees as an ordinary miss.
Expiry, flushing and garbage collection
Every entry stores an absolute timeout (above it is exactly 3,600 seconds after timestamp). An expired entry is removed when it is read. Entries saved with no expiry get a default lifetime by group: 14 days for options, posts and terms, one day for transients and stale query results, and four days for everything else.
Deleting a single key truncates its file to zero bytes under an exclusive lock and invalidates its OPcache entry. A full flush empties the directory. Files cost little, but they still have a cost, so a garbage collector runs on WP-Cron every five minutes. It removes empty and leftover files, entries past their timeout or the maximum lifetime, and anything beyond the limits, which default to 50,000 files and 500 MB on disk.
Other features
The plugin does more than the cache itself. The parts of the readme's feature list I would point out:
- Advanced post caching: caches post queries, adapted from Automattic's Advanced Post Cache.
- Object cache precaching: remembers which keys a URL used and loads them early on the next visit.
- Menu and translation caching, both off by default.
- Autoload trimming: an option that keeps only core options in the autoloaded
alloptionscache, so a plugin's large settings are loaded only when used. - Query tweaks: term count updates and post, page and comment counts.
- Admin pages: object cache and OPcache statistics, an OPcache viewer and a cache log.
- WP-CLI commands, and multisite and multi-network support.
Some of this code came from other projects, and credits.txt names the sources. The cron event list comes from WP Crontrol and Query Monitor. Bulk-edit limiting and post caching come from Automattic's VIP plugins. Term counts come from Lightweight Term Count Update, and the exporter comes from Symfony VarExporter.
Installing it
Install it from the dashboard (search for "Docket Cache") or with WP-CLI. Activating the plugin installs the object-cache.php drop-in for you, and caching starts after a few seconds.
$ wp plugin install docket-cache --activate
Plugin installed successfully.
Activating 'docket-cache'...
Plugin 'docket-cache' activated.
Success: Installed 1 of 1 plugins.
$ wp cache status
---------------:--------------------------------
Cache Status : Enabled
Cache Path : /wp-content/cache/docket-cache
Cache Size : 0B
---------------:--------------------------------If another plugin owns the drop-in, wp cache dropin:enable, dropin:disable and dropin:update manage it. The plugin also adds flush:precache, flush:transient, run:gc, run:stats and a few more under wp cache. The full list and all settings are documented on docketcache.com. The plugin page is on wordpress.org and the source is on GitHub.
Measuring it
I built a temporary test site in my local Docker setup: WordPress 7.1.3, PHP 8.1 with OPcache, MariaDB 11.8, nginx and the default Twenty Twenty-Five theme. I generated 200 posts, 12 pages, six categories, eight tags and 120 comments. A temporary mu-plugin printed the query count and generation time at the end of each page:
<?php
// Temporary mu-plugin: print query count and generation time as an HTML comment.
add_action( 'shutdown', function () {
if ( ( defined( 'WP_CLI' ) && WP_CLI ) || is_admin() || wp_doing_ajax()
|| ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
return;
}
printf(
"\n<!-- dctest q=%d t=%s oc=%s -->\n",
get_num_queries(),
timer_stop( 0, 4 ),
wp_using_ext_object_cache() ? 'ext' : 'none'
);
}, PHP_INT_MAX );The oc field shows whether a persistent cache was active. For three URLs I made five warm-up requests with curl -k and then 42 measured ones, without the plugin and then with it on a warm cache. The values are medians:
| Page | Queries before | Queries after | Time before | Time after |
|---|---|---|---|---|
| Home page | 25 | 0 | 46.0 ms | 43.6 ms |
| Single post | 33 | 0 | 45.9 ms | 41.6 ms |
| Category archive | 29 | 0 | 46.8 ms | 44.1 ms |
The query count fell to zero. The time saved was small, between 2 and 4 ms, because the database ran on the same machine with tiny tables and every query was already cheap. Most of the 42 ms left is PHP building the page, which an object cache does not affect. The saving grows when queries are expensive or the database is busy or remote. The first request after a flush is the slow one: the home page took 29 queries and 66.6 ms while it wrote its cache files.
After crawling all 224 URLs on the site the cache held 2,492 files:
$ wp cache run:stats
---------------:----------
Object size : 1005K
File size : 1M
Total file : 2492
---------------:----------
Success: Executing the cache stats completed.The data itself was 1.3 MB, but du reported 11 MB because each small file takes a whole filesystem block. A script calling opcache_get_status() showed 2,441 of the files compiled in OPcache, using 3.4 MiB of its memory. All of these numbers come from one local Docker test site on one laptop. Your site will differ.
Limits, and when Redis is better
The cache lives on the local disk of one server. With several web servers behind a load balancer, each one has its own copy, and a delete on one server does not reach the others. Redis is shared, so use it there.
Large caches are the other weakness. Every entry is a file, so a big site uses many inodes (some shared hosts limit those) and a lot of OPcache memory. PHP's default opcache.max_accelerated_files is 10,000, below Docket Cache's own default limit of 50,000 files, so check both settings on a busy site. The plugin FAQ gives the same advice: for a busy WooCommerce store, or a VPS where Redis is available, use Redis. Docket Cache is meant for small and medium sites that have PHP and OPcache and nothing else.