This is part 4 of a series on splitting WordPress into two products, Classic and Next. [Part 1] made the case for the split. [Part 2] talked about the kernel. [Part 3] talked about the admin and editor. This post is about performance and security.
I’m grouping them together because they share a structural trait. Every serious WordPress host already does most of this work, just in their own wrapper, because core won’t.
The hosts-know-this list
Every managed WordPress host (Pressable, Kinsta, WP Engine, Pantheon, VIP, Cloudways, Rocket.net) ships the same set of patches in different wrappers. A required persistent object cache. A real page cache at the edge. WP-Cron replaced by real cron. A batched autoloader. A queue for async work. A dozen query-layer workarounds for WP_Query‘s structural limits.
This list is not secret. It’s the actual minimum for WordPress to behave like software at any reasonable scale. The fact that it’s every host’s job to bolt it on is, I think, the indictment.
To be clear, I’m not blaming the hosts here. The hosts do what they do because they have to. Everyone is solving the same problems with their own wrenches. Core could ship the wrench.
The cold-boot target I owe an answer on
In part 2 I flagged that any credible version of Next has to commit to a defensible p95 cold-boot target, and that I’d give a number in this post. Here’s the number.
Next’s p95 cold-boot target is 50 milliseconds on PHP 8.3 with opcache preload configured, on commodity managed hosting (think: a 2-vCPU container, 1 GB RAM, SSD storage, Redis on the same network). p99 cold-boot target is 80 milliseconds. By “cold boot” I mean: a fresh PHP worker process, opcache primed by preload, no warm request state, executing through the kernel up to the point of dispatching to a controller.
If Next can’t hit those numbers, the proposal fails. Not “the proposal needs more work.” Fails. Because the entire pitch of Next is that it gives professional developers a modern runtime without making hosts pay a meaningful boot tax compared to Classic. If the tax is meaningful, hosts won’t deploy it, and Next becomes a hobbyist niche regardless of how nice the developer experience is.
Why those numbers specifically. Modern Symfony applications with a compiled container and opcache preload boot in the 15 to 30 millisecond range on similar hardware. Drupal 10 on the same setup hits 40 to 60 milliseconds. WordPress Classic on a warm opcache is in the 20 to 40 millisecond range, though the variance is much wider because the procedural runtime does more work per request than per boot. 50 milliseconds is achievable, defensible, and close enough to Classic’s range that hosts won’t flinch. It’s also the number where the three-part recipe from part 2 has to actually hold together: preload mandatory, container compiled not reflected, middleware pipeline lean by default.
Object cache as a hard dependency
Core today: WP_Object_Cache is an in-memory per-request cache. object-cache.php as a drop-in swaps in Redis or Memcached. Without a drop-in, every page load does the same wp_options reads, the same meta lookups, the same term relationships. The wp_cache_* API is thin and has no tagging, no hierarchical invalidation, no atomic increment beyond what Memcached or Redis provide directly, and no concept of cache dependencies.
Drupal’s cache API has cache.default as a service with tags, contexts, and max-age built into every cache item. You invalidate by tag (node:42), and every cache entry that declared that tag is gone. Laravel’s Cache::tags(['user:1', 'posts'])->remember(...) does the same. Symfony’s Cache component speaks PSR-6 and PSR-16 with tag-aware adapters. None of this is hard. The reason WordPress doesn’t have it is that persistent cache is optional, so tagged invalidation is meaningless if the backend can’t actually persist tags.
Next requires a persistent object cache. Period. The installer checks for Redis, Memcached, Valkey, or APCu (acceptable for single-node) and refuses to complete without one. The API gains tags and atomic locks:
$cache->tags(['post:' . $id, 'author:' . $post->authorId]) ->remember('post-view:' . $id, ttl: Duration::minutes(15), fn() => $renderer->render($post));$cache->lock('import-job', ttl: Duration::seconds(30))->block(10, function() { /* ... */ });
What breaks: shared hosts that don’t offer Redis.
What wins: every plugin stops reinventing transients with its own TTL bugs. Transients die or become a thin shim.
The autoload = yes disaster, and the postmeta question
wp_load_alloptions() loads every row in wp_options where autoload = 'yes' into memory on every request. It’s been a known problem since around 2015. Plugins write 100 KB, 1 MB, 10 MB, 80 MB rows into autoloaded options. A big commerce site’s wp_load_alloptions() can take 400ms just to unserialize. I’ve watched this happen on real customer sites more than once. The 6.4 to 6.6 work on wp_set_option_autoload() and smarter defaults was overdue and welcome, but it’s a Band-Aid.
The actual fix is that options is two conflated concerns. Structured configuration (site URL, admin email, active theme, enabled plugins) and a catch-all key-value store (plugins’ settings, cron events, transients, update caches). Drupal split these years ago. Config API (exportable, typed, versioned) and State API (key-value, not exported, not user-facing).
Next splits them. wp_config table (small, typed, fully loaded, versioned and exportable via wp config:export) and wp_kv (key-value, explicit load, nothing autoloaded, callers fetch what they need, persistent cache handles the hot path). wp_options exists in Classic and is a read-only compat view in Next.
Now, the part 2 IOU on wp_postmeta.
In part 2 I wrote a Post readonly class with a $meta: PostMeta field and admitted that the field was doing more work than one line of code can honestly do. The problem, restated: wp_postmeta is an EAV (entity-attribute-value) table where every custom field is a row, values are serialized PHP blobs of unbounded size and structure, and a single post can have thousands of meta rows. You cannot hydrate that into a readonly value object at read time without an N+1 query disaster. You cannot lazy-hydrate it without contradicting the readonly contract. And meta_query produces some of the most catastrophic SQL the platform emits at scale, because every clause is another self-join against wp_postmeta with no useful indexes.
Here’s the answer, and I want to be honest that it’s a two-part answer because the problem is two problems.
Part one is the data model. wp_postmeta stays in Classic, unchanged. Next ships a typed meta system where meta keys are declared (in code or via the Schema GUI from part 3), each declared key has a known type and cardinality, and storage for typed meta lives in either dedicated columns on wp_posts (for hot, low-cardinality fields like featured, excerpt_override, seo_title) or in purpose-built tables per content type (a wp_review_meta table for the review post type, etc.) with proper indexes. Undeclared meta is still possible for backwards compatibility on the bridge, but it lives in a wp_kv-style fallback table and the system warns about it.
This is, in spirit, what Drupal did with its Entity API and what Sanity does with its schema-first storage. The EAV escape hatch exists for genuinely dynamic data. The typed columns and per-type tables handle the 95% case that EAV has historically been used (and abused) for.
Part two is the access pattern. Post::$meta is not a pre-hydrated bag. It’s a typed accessor backed by a repository:
$post = $posts->find($id);$rating = $post->meta->get(ReviewMeta::Rating); // typed return, single query batched at request scope$pros = $post->meta->getMany(ReviewMeta::Pros); // typed return, same batch
The repository batches meta loads at request scope. When you call $posts->with(['meta'])->wherePostType('review')->get(), all reviewable posts’ declared meta keys come back in one or two queries, not 200. When you access an undeclared meta key, you get a typed warning (in dev) or a metric (in prod) so the path back to declaring it is obvious.
This doesn’t preserve the “you can store anything in postmeta and it’ll work” lawlessness that Classic offers. That’s the point. Classic offers lawlessness. Next offers a typed contract. Both are valid products for different populations, which is the whole thesis of the series.
Query builder: kill WP_Query for new code
WP_Query is a query builder, an iterator, a global state mutator, and a template context, all at once. meta_query produces catastrophic SQL at scale, for the reasons described above. tax_query has the same shape. Anything non-trivial ends at $wpdb->prepare with a hand-written SQL string.
The model to copy is Eloquent’s query builder, or Doctrine DBAL if you want to stay framework-agnostic:
$posts = $em->posts() ->wherePostType(PostType::Post) ->whereStatus(PostStatus::Publish) ->whereTaxonomy('category', in: ['news', 'analysis']) ->whereMeta('featured', true) ->with(['author', 'terms', 'featuredImage']) ->orderBy('createdAt', Direction::Desc) ->limit(20) ->get();
with() solves the N+1 query problem that _embed half-solves in REST. Meta queries are typed (whereMeta('price', Op::Between, [10, 50])) and resolved against the typed meta layer described above, not against wp_postmeta‘s string blob soup. The builder emits CTE-aware SQL on MySQL 8+ and falls back otherwise. The underlying engine can be Doctrine DBAL plus a hand-rolled hydration layer, or Cycle ORM, or pure DBAL with data mappers. Implementation detail.
Core continues to ship WP_Query in Classic. Next exposes PostRepository, UserRepository, TermRepository, CommentRepository, all implementing a Repository interface, all composable. Plugins define their own repositories via the container.
Background jobs: kill WP-Cron, adopt a real queue
WP-Cron is a pseudo-cron that runs on pageload, or with DISABLE_WP_CRON turned off and real cron hitting wp-cron.php. Failure modes: low-traffic sites never run their own cron, duplicate runs happen on concurrent requests, long-running tasks block the user’s pageload, failed jobs have no retry, no backoff, no visibility. WooCommerce’s Action Scheduler exists because WP-Cron is unusable at scale. It’s already in tens of millions of WordPress installs under the hood.
Next ships a real queue contract, modeled on Laravel queues and Symfony Messenger:
// A jobfinal readonly class SendOrderConfirmation implements Job { public function __construct(public OrderId $orderId) {}}// A handlerfinal class SendOrderConfirmationHandler { public function __construct(private Mailer $mailer, private OrderRepository $orders) {} public function __invoke(SendOrderConfirmation $job): void { $order = $this->orders->find($job->orderId); $this->mailer->send(new OrderConfirmationMail($order)); }}// Dispatching$queue->dispatch(new SendOrderConfirmation($order->id)) ->onQueue('transactional') ->delay(Duration::seconds(30));
Transports: Redis, database (for shared hosts), SQS, Beanstalkd, RabbitMQ. Failed job table. Retry with exponential backoff. wp queue:work as the worker command. wp queue:horizon or equivalent for observability. Action Scheduler becomes the reference implementation. WP-Cron survives in Classic and as a database transport in Next for sites that can’t run a worker.
Async and concurrent HTTP
WP_Http is a synchronous Requests-library wrapper. Plugin update checks, RSS fetches, oEmbed lookups, remote image sideloading, license-server pings, all sequential. A dashboard that checks updates for 40 plugins sequentially is a 30-second dashboard. I have seen this exact thing in customer sites more times than I can count.
PSR-18 (the HTTP client interface) gives us the contract. Symfony HttpClient has true async via curl multi handles. Guzzle has promises. PHP 8.1 fibers make proper cooperative concurrency possible without a full async runtime like ReactPHP or AMPHP.
$promises = [];foreach ($plugins as $plugin) { $promises[$plugin->slug] = $http->requestAsync('GET', $plugin->updateUrl);}$responses = Async::all($promises); // parallel, bounded concurrency
Next ships PSR-18 plus PSR-17 (HTTP factories) plus a fiber-based concurrency helper. Classic stays synchronous.
REST API: it needs a reset
The REST API is one of the more successful pieces of 2015-era WordPress work, but it has not aged gracefully.
The boot cost issue: every /wp-json/* request loads most of WordPress. Themes, plugins, admin glue behind feature flags. A REST-heavy single-page app pays the same boot tax as a page request.
The auth issue: cookie plus nonce works same-origin. Application Passwords (5.6+) is basic auth over HTTPS with a user-meta stored password (no scopes, no expiry, no revocation beyond deletion). OAuth2 is plugin territory. JWT is plugin territory. For 2026, this is honestly a little embarrassing.
The shape issue: REST-ish with _embed and _fields as ad-hoc shape controls. Drupal’s JSON:API offers sparse fieldsets, includes with cardinality, filter/sort as first-class query params, deterministic pagination. Craft ships native GraphQL. Sanity’s GROQ is a query language designed for content. WPGraphQL proved the pattern works on WordPress, but it’s still a plugin.
Next’s content API layer is separable from the admin runtime. A dedicated bootstrap path that loads only what’s needed to serve a read request. A REST implementation that conforms to JSON:API (or a GraphQL endpoint, or both, pick what your tenant needs). A first-class OAuth 2.1 plus OIDC server. Auth tokens have scopes (posts:read, posts:write:own, media:write). Token revocation is real. Refresh tokens exist.
Page caching: stop pretending core doesn’t know
Every real WordPress deployment has a page cache. The plugin landscape is a taxonomy of bug surfaces: W3TC, WP Super Cache, WP Rocket, LiteSpeed Cache, Batcache, host-level (Kinsta, Pressable, VIP), Cloudflare APO. The bugs are always the same. Private content served to the wrong user. Logged-in pollution of the anonymous cache. Cookie-based varying gone wrong. Cache key hash collisions. Purge semantics that miss one of the six places the page is cached.
Ghost ships with page caching in core. Craft Pro has static output caching. Statamic has static caching built in. Drupal has an internal page cache, a dynamic page cache, BigPipe, and lazy builder placeholders, all first-party.
Next ships first-class HTTP caching:
- Proper
Cache-Control,Surrogate-Control,Surrogate-Key/Cache-Tagheaders per response. - Tag-based purge that federates to Varnish, Fastly, Cloudflare, Nginx’s fastcgi_cache, and the built-in file/Redis cache.
- Cache varying by explicit, declared dimensions (auth state, device class, geo). No cookie-sniffing.
- An ESI/edge-side include story for BigPipe-style partials (the Interactivity API’s islands plus
Surrogate-Capabilitynegotiation). - Private content marked as such at the handler level. Impossible to cache by default.
This is, in my view, the single change with the biggest real-world performance impact, and it’s been sitting in plugin-land for 17 years because core has been reluctant to bless one.
Early hints, streaming, islands
103 Early Hints (RFC 8297) is a one-line win for WordPress. Preload fonts and the critical CSS before the 200 comes back. Chromium, Cloudflare, Fastly all support it. Next ships it for free on theme-declared critical assets. Streaming responses (flush()-era stuff done properly via PSR-7 streams) give us Turbo-style progressive rendering. The Interactivity API plus Surrogate-Key plus streaming gives us an islands architecture that makes sense inside WordPress’s content model, instead of requiring a headless stack.
That’s performance. Now the security side.
Security: stop issuing revolvers to plugins
The security conversation around WordPress has been dominated by which scanner caught which vulnerability in which plugin, and that’s the wrong conversation. The structural problem is that WordPress’s security model assumes every plugin is trusted with the whole process, defends against attackers from outside, and has basically no defense against a compromised or malicious plugin. Given that the plugin supply chain has no mandatory security review, this is the real risk surface.
Capabilities: primitive, meta, and what’s missing
current_user_can('edit_post', 42) flows through map_meta_cap, which translates the meta cap into a set of primitive caps (edit_posts, edit_others_posts, edit_published_posts, edit_private_posts). Primitive caps are stored in wp_user_roles, a serialized PHP array in wp_options, editable only by code that knows the serialization format. Multisite adds super_admin as an out-of-band concept.
There is no resource-scoped permission grant. “User A can edit this specific post B” is expressible only by hooking map_meta_cap and writing custom logic. There is no permission inheritance, no time-bounded grant, no delegation, no audit trail.
Models worth copying:
- Drupal. Permissions declared in
module.permissions.yml, assigned to roles via admin, checked with$user->hasPermission('edit any article content'). Permissions are strings, but they’re declared and discoverable. - Laravel Gate plus Policies. Class-based authorization (
UserPolicy::update(User $actor, Post $subject): bool), with abilities as methods, resource-scoped by construction. - Symfony Security Voters. Vote on (attribute, subject) pairs, composable, unanimous/affirmative/consensus strategies.
- Google Zanzibar (the theoretical gold standard). Tuple-based relationships (
post:42#editor@user:1), consistency guarantees. This is what Slack, GitHub, and Notion use.
Next ships a Policy plus Permission model where permissions are declared in manifests, assigned to roles through a proper schema (a real permissions table, not a serialized option), checked via $auth->can(Ability::EditPost, $post), and resource-scoped permissions work by construction. Zanzibar-style is the aspiration for multisite at scale.
Nonces are not CSRF tokens
wp_create_nonce('action') returns a token derived from user_id + token + action + scheme hashed through wp_hash, valid for a 12 to 24 hour tick window, reusable within that window, shared across page loads. This is a weak CSRF defense that survives largely because SameSite cookies now do most of the actual work.
The problems, concretely. Nonces are not per-request (so a leaked nonce from, say, a reflected content echo, gives an attacker a 12-hour window). Action granularity is whatever the developer typed, which is frequently broad. Nonces don’t rotate on privilege change. And WordPress’s own habit of putting nonces in URLs (via wp_nonce_url) leaks them to referrer headers, logs, and analytics.
Modern defense stack:
SameSite=Laxcookies by default (WordPress already does this post-2021).Origin/Referercheck on state-changing requests.Sec-Fetch-Site/Sec-Fetch-Modeheader checks.- Per-session cryptographic CSRF tokens (rotated on login, bound to session).
- Content Security Policy with strict nonces for
script-src(more on this below). - Trusted Types for DOM XSS sinks.
Laravel’s VerifyCsrfToken, Symfony’s CsrfTokenManager, Django’s per-view CSRF, all ship this by default. Next ships it via a CSRF middleware. Classic keeps nonces.
Plugin sandboxing: the honest admission
This is the section I keep coming back to. A WordPress plugin can file_get_contents('/etc/passwd'), exec() whatever it wants, read every other plugin’s secrets, write to every other plugin’s tables, make any outbound HTTP call, and exfiltrate anything. Every plugin, all the time. There is no permission model at the plugin boundary. This is, in my view, the real reason WordPress security posture is what it is.
Ideal model: a manifest per plugin declaring what it needs, modeled on browser extension manifest.json or Deno’s --allow-* flags:
{ "name": "my-plugin", "permissions": { "database": ["read:posts", "read:users", "write:meta:my_plugin_*"], "http": { "outbound": ["api.example.com"] }, "filesystem": ["read:uploads", "write:uploads/my-plugin/"], "capabilities": ["my_plugin_manage"], "admin": { "menu": ["my-plugin"], "settings": ["my_plugin_*"] } }}
Enforcement is where it gets hard. PHP has no process-level capability model. Realistic options, in order of how feasible they are:
- Declared-but-not-enforced, phase 1. The manifest is part of the public contract. The store surfaces it. Users see “this plugin wants to make outbound HTTP” at install. Tooling warns when a plugin uses APIs it didn’t declare. This alone is worth doing and is cheap.
- API-level enforcement. Core’s
$wpdb,WP_Http,WP_Filesystemconsult the manifest and refuse undeclared access. This enforces against well-behaved plugins. A malicious plugin can stillfile_get_contentsdirectly. But it stops the 99% case and makes abuse attributable. - Static analysis at install. A PHPStan/Psalm-based scanner flags direct use of
exec,file_get_contentswith user input, direct$wpdbuse bypassing the API, and friends. Block or warn at install based on trust level. - True isolation via WebAssembly. This is where Playground gets really interesting. PHP-in-WebAssembly with explicit syscall boundaries is a real sandbox model. For untrusted plugins running server-side, the architecture exists in prototype. This is a 5-year bet, but it’s the only path to actual sandboxing.
Shopify’s app permission model (OAuth scopes) is an adjacent thing worth stealing. The model where “this app can read orders, write products” is negotiated at install time and surfaced in the UI. WordPress plugins aren’t OAuth apps, but the user-experience pattern ports over.
Next ships (1) and (2) immediately, (3) as a recommended scanner, (4) as a research track bankrolled by Playground.
Supply chain: the .org repo’s silent trust
The .org plugin repo is a piece of infrastructure that the entire ecosystem relies on, and the trust model is mostly “we hope the reviewer caught it.” There’s no signing. There’s no SBOM (Software Bill of Materials). There’s no provenance attestation. There’s no vulnerability database that’s first-party and queryable. WPScan exists, Patchstack exists, Wordfence exists, but those are private/commercial and partial.
Compare to npm’s recent provenance work via Sigstore, PyPI’s two-factor and trusted publishers, GitHub’s dependency review API, RustSec’s crate advisory DB. The PHP ecosystem (via Composer and Packagist) has security advisories and signing in flight. WordPress is behind on every single one of these.
Next ships:
- Signed releases via Sigstore-style attestation.
wp plugin installverifies signatures. - A first-party advisory database, federated with WPScan, Patchstack, and friends.
wp plugin:auditreturns vulnerabilities for installed plugins against the advisory DB.- SBOM generation per release (CycloneDX format).
- Mandatory two-factor for plugin authors via WebAuthn/passkeys (passwords as fallback only).
Classic gets the advisory DB and plugin:audit. Next gets all of it.
CSP and Trusted Types
wp_add_inline_script exists. WordPress core also emits inline scripts via wp_localize_script, settings pages, the editor, blocks. Per-script nonces for Content Security Policy are not threaded through this path. Most production WordPress sites either don’t ship CSP, ship a permissive unsafe-inline CSP, or ship a strict CSP that breaks half their plugins.
Rails 5.2+ auto-generates per-request CSP nonces and ties them to every javascript_tag. Symfony has similar. Next enforces: every wp_enqueue_script, every wp_add_inline_script, every block’s frontend script, every editor script, is auto-nonced. script-src 'strict-dynamic' 'nonce-...' becomes the default CSP. Inline event handlers (onclick=) are banned in core output and flagged in plugin output. Trusted Types enabled.
Where this leaves us
Performance and security are the two areas where the gap between what core ships and what production WordPress actually requires is widest. Every host has been quietly fixing the same gaps for a decade. The question is not whether these problems are known. They’re known. The question is whether core is willing to draw the line and say “from here forward, this is the floor.”
Next post is about the plugin economy. Declared contracts. Dependency declaration that actually works. Semantic versioning as a real commitment instead of folklore. A backwards compatibility policy that lets the project break things responsibly when it needs to. The migration plan, including the economic model for who pays to keep Classic alive, comes in part 6 to wrap up the series.
More soon.
AI Disclosure: Featured image generated by Gemini, and Claude assisted with the refinement of my choices, arguments, and editing of this post and series.

Leave a comment