Optimizing MediaWiki Performance through Custom Extension Development
A practical guide to MediaWiki performance extension patterns: profile first, cache with WANObjectCache, defer with jobs, and avoid the caching anti-patterns.
Performance work in MediaWiki usually starts with caching and configuration, but there is a third lever that is often underestimated: extension code. Core does the generic work, while a well-designed extension can batch, cache and defer the specific operations that only your wiki performs. This article is a practical guide to the patterns that actually move response times — verified against current MediaWiki practice — and the traps that waste the effort.
Find the bottleneck before writing code
The first rule: measure, then optimize. MediaWiki ships with built-in profiling support activated through $wgProfiler (for example with the Xhprof backend or the built-in profiler output), and Manual:Performance tuning is the authoritative starting point. Three things are responsible for most latency on self-hosted wikis:
- Database round-trips that could be batched into fewer queries (check with the "database queries" section of the profiling output)
- Parser work repeated on every request — template-heavy pages re-parse more than people expect
- Uncached computation in hooks that run on page views
Application-level profiling beats URL timings here: enable $wgProfiler with ['class' => 'ProfilerXhprof'] on a test instance, and look for functions that dominate the trace of a typical page view. Pay attention to ParserCache::get and Parser::parse entries, and count queries per request.
Choosing the right cache layer
MediaWiki has several cache backends with different lifetimes and semantics. Using the wrong one is the most common extension anti-pattern:
ObjectCache::getLocalClusterInstance()— the shared cache (memcached/Redis if configured, otherwise the DB), the default for small computed valuesObjectCache::getLocalServerInstance()— per-server cache (APCu), extremely fast but not consistent across a multi-server clusterWANObjectCache— the cache for content-derived data: it handles inter-datacenter propagation, cache stampedes and cross-cache invalidation. Any data derived from page content or database state should live here
Since MediaWiki 1.35, the recommended way to get these in extension code is dependency injection via service wiring, not static calls — register your services in extension.json and inject BagOStuff or WANObjectCache (via MainObjectStash / the WAN cache service) into your classes.
The hook pattern that scales
The classic pattern: wrap an expensive per-request operation in a hook call and cache its result. Modern code should use an injectable WANObjectCache and its stampede protection:
$result = $wanCache->getWithSetCallback(
$wanCache->makeKey( 'myext', 'expensive-result', $pageId ),
$wanCache::TTL_DAY,
static function () use ( $pageId ): array {
// Expensive computation: DB joins, API calls, regex-heavy parsing
return doExpensiveWork( $pageId );
}
);
getWithSetCallback handles the "thundering herd" case: while the value is being recomputed, stale-but-reusable data is served, and only one process does the work. Rolling your own add()-based locks for this is both more code and easier to get wrong.
Batching and deferring work
Not everything should run during the request at all:
- DeferredUpdates — register post-response work with
DeferredUpdates::addCallableUpdate()so page views do not pay for it; it runs after the response is sent - Job queue — heavy, non-user-facing data work belongs in jobs;
JobQueueGroup::getDefaultQueue()and a job class registered inextension.json. MediaWiki runs jobs viarunJobs.phpor MultiJobRunner.php - Query batching — replace per-item
selectRow()calls in loops with singleselect()calls; for link-heavy features use the sharedLinkCacheand batch title lookups
When a custom table is justified
Structured data too large or too queryable for key-value caches deserves its own table — for example revision counts per user or computed link graphs. Declare tables in extension.json using the abstract schema format, and update.php creates them on install and upgrade:
"tables": {
"myextension_cache": {
"columns": {
"ce_key": { "type": "binary", "length": 255, "notnull": true },
"ce_data": { "type": "blob", "notnull": false },
"ce_expiry": { "type": "integer", "notnull": false }
},
"indexes": [ [ "ce_expiry" ] ],
"pk": [ "ce_key" ]
}
}
This is better than shipping raw CREATE TABLE SQL: the schema is versioned, reusable across MySQL/PostgreSQL/SQLite, and stays in sync with upgrades. Index invalidation instead of time-based expiry: bump rows when the source data changes, rather than letting caches go stale.
Measuring the result
Verify with the same tooling you used to diagnose. Compare the profiled trace of a representative page before and after the change — the expensive function should drop out of the hot path, and the query count should fall. For load testing, tools like ab, siege or k6 work, but use them against a staging copy of the database, not production. A realistic target is a visible drop in p95 response time on template-heavy pages, not a headline milliseconds number on the homepage.
Pitfalls that waste the effort
- Caching user-specific output in a shared cache — anything depending on the logged-in user, language or skin must be keyed by those, or not cached at all
- Caching what never changes — if the computed value is static per page revision, make sure the cache is invalidated on edits (WANObjectCache keyed by revision ID handles this for you)
ParserFirstCallInit-style hooks doing heavy work per parse — keep registration cheap; do the work lazily behind a cache- Ignoring Wikimedia patterns — the Wikimedia engineering team publishes its caching design (e.g. WANObjectCache, object cache comparison); reinventing these poorly is the classic self-inflicted wound
- Schema changes without maintenance scripts — if your extension changes table structure, add a maintenance script or use the schema versioning in
extension.jsonso upgrades stay repeatable
Roadmap
- Profile a typical request and pick one dominant, repeatable cost
- Cache it with WANObjectCache keyed by the data it depends on, or defer it with DeferredUpdates/jobs
- Re-profile, confirm the cost left the hot path, and watch query counts
- Repeat. A handful of these rounds buys more than most generic server tuning
Custom extension development is not the first performance tool to reach for — configuration, object caching setup and parser cache sanity come first. But for the specific, repeatable costs your wiki has (a heavy template, a custom report, an integration), a small, well-instrumented extension is the surgical fix that generic tuning cannot provide. The performance manual and the extension best practices page are the two references worth reading before you start.