Administrator reviewing forum performance charts on a widescreen monitor in a cool steel-blue office

A slow XenForo board is rarely “the software.” It is debug left on, a guest request that rebuilds CSS from disk, a 400 KB icon webfont, a permission rebuild at 20:00, or Cloudflare minifying the JavaScript XenForo already deferred. Core Web Vitals are how that mess shows up in Search and on a phone: the first image takes too long (LCP), the first tap does nothing (INP), the layout jumps when a widget arrives (CLS).

This is not the 2.3 upgrade decision. That article already covered why 2.3 dropped jQuery, shipped style variations, and converted uploads to WebP. Assume you are on 2.3. This is the runtime: what you flip in src/config.php, what you rebuild in Tools, what you must not let a CDN “optimize,” and the first hour when members say the forum died.

You will not get a 100 on PageSpeed Insights. A logged-in thread view has to know who you are. Aim for a guest first screen that paints fast, a member session that stays under a second of PHP, and an admin who can name the three things that still cost money.

What “fast” means on a forum

Translate the three Google numbers into board language before you install a cache add-on.

Vital What a member feels What usually causes it on XenForo
LCP (Largest Contentful Paint) The hero, logo, or first thread list takes too long to appear Unconverted JPEG attachments, a full Font Awesome webfont, a remote Google Font, a portal slider that waits on JS, TTFB because PHP ran
INP (Interaction to Next Paint) Tap “Reply” or open a menu and the page hangs Heavy third-party JS, an add-on still on jQuery, main-thread work from a live chat or “who is online” poll, Cloudflare rewriting scripts
CLS (Cumulative Layout Shift) The thread list jumps after load Images without reserved size, a lazy-load add-on fighting a slider, a notice or ad that injects after first paint

TTFB (time to first byte) is not a Core Web Vital, but it is the floor under LCP. If PHP still compiles the guest homepage on every hit, no amount of WebP will save you. Official 2.3 materials treat guest page caching as the tool that stops that: entire pages for people who are not logged in. It is off by default because it eats cache RAM. Turn it on only after you have given the cache backend somewhere to live.

Do not chase a mobile PageSpeed score on an admin session with debug on. Measure a guest window in a private browser, then a normal Registered account, then the portal if you pointed Index page route at one.

Measure before you flip switches

Official docs do not publish “good enough” query counts. Community sources in this research also do not. Use the tools you already have and write the numbers down.

  1. Open the board as a guest in a private window. Note first-screen time by eye. Then run PageSpeed Insights or WebPageTest on the homepage and one busy thread. Save LCP / INP / CLS / TTFB.
  2. Repeat logged in. The gap is how much your member chrome (widgets, notices, conversations) costs.
  3. If you must see SQL, do it on staging with $config['debug'] = true. Official docs are blunt: debug on production makes page generation significantly slower and prints internal queries to visitors. Never tune a live board with debug on.
  4. Repeat the guest test after each change. One variable. If you enable Redis, WebP, and a CDN rule in the same hour, you will not know which one broke the style switcher.

A useful target for a mid-size board: guest homepage LCP under ~2.5 s on a mid-range phone, TTFB under ~400 ms from a nearby region, no layout jump on the thread list. If you are already there, stop. Go do the node tree or engagement work. Speed is not a hobby.

Turn off the things that make PHP do extra work

Three official switches belong at the top of src/config.php. They are not “optimizations.” They are “stop shooting yourself.”

$config['debug'] = false;
$config['development']['fullJs'] = false;
$config['enableGzip'] = true;

Debug mode. Official 2.3 docs: enabling $config['debug'] = true on a production site “severely hurts performance” and exposes internal SQL. If a developer left it on after a ticket, that is your incident.

Full JS. $config['development']['fullJs'] = true bypasses the minified, rolled-up JavaScript and serves the full files. Official docs: more HTTP requests, more bandwidth, a slower site. It is a development switch. Leave it false.

Gzip. $config['enableGzip'] = true compresses the HTML and CSS XenForo generates. The browser decompresses it. Official docs say this “significantly speeds up page loading times.” Set it false only if the web server or CDN already compresses the same response and you have confirmed you are not double-wrapping. Do not disable it “to see raw HTML” on a live board.

Two more official “do not” items that are not speed features but will look like a down site:

  • A wrong $config['cookie']['path'] (or domain) that does not cover the XenForo root. Members cannot stay logged in. Every request looks like a guest. You then enable guest page cache and wonder why logged-in people see a cached guest page — they were never logged in.
  • $config['ipv4CidrMatch'] / $config['ipv6CidrMatch'] set to 0. Official docs treat that as a development bypass for VPN session disconnects. Do not ship it.

PHP floor in official 2.3 materials: 7.2 minimum, 8.3 or 8.4 recommended for opcode cache and execution time. Database: MySQL 5.7 minimum, MySQL 8.0 or MariaDB recommended (Percona is listed as compatible). Required extensions: mysqli, gd with JPEG, pcre, curl, spl, simplexml, dom, json, iconv, ctype. Enhanced search wants Elasticsearch or OpenSearch 7.2+. If you are still on PHP 7.4 because “it works,” the upgrade article is the conversation. This one assumes the runtime is current.

Cache: Symfony, guests, and Redis

XenForo 2.3 replaced the old Doctrine cache library with Symfony Cache. Official materials describe dummy wrappers for old code and a native adapter for new code. The public cache manual (docs.xenforo.com/manual/config/cache) still documents the config.php providers: ApcCache, Filesystem, Memcached, Redis, WinCache, XCache. A Redis block looks like this (official defaults: port 6379, optional password, database 0, persistent false):

$config['cache']['enabled'] = true;
$config['cache']['provider'] = 'Redis';
$config['cache']['config'] = [
    'host' => '127.0.0.1',
    'password' => 'password',
];

Guest page cache is a separate context. Official 2.1+ rule: $config['pageCache']['enabled'] = true only works if you also define $config['cache']['context']['page'] with its own provider. They recommend a separate cache instance so huge guest HTML does not evict sessions. A cached response sends X-XF-Cache-Status: HIT. Optional knobs: lifetime (default 300 seconds), recordSessionActivity (default true), routeMatches to limit which routes are cached. Session caching is $config['cache']['sessions'] = true — official warning: do not put sessions in APC if the store can fill up, or people will not stay logged in.

Two official master switches:

$config['cache']['enabled'] = true;      // or false to kill the whole cache layer
$config['pageCache']['enabled'] = true;  // guest full-page cache; off by default

Application cache ($config['cache']). This is the data registry, compiled templates, CSS cache, permission combinations. Without it, 2.3 still works. It reads xf_data_registry and friends from the database on every page. Community consensus for a busy board is: do not leave that on disk plus MySQL. Put it in RAM.

Guest page cache ($config['pageCache']). Official docs call this “extremely powerful”: it stores entire pages for guests. It is disabled until you allocate cache resources, because a popular homepage times a full HTML copy per URL (and sometimes per style / language). Enable it when:

  • Most of your hits are guests (Search, Discord previews, people who have not signed in).
  • You have Redis, a local RAM cache, or a web-server cache that can hold those copies.
  • You have tested a logged-in session after enabling it. Authenticated members must bypass the page cache. If they do not, you will serve a guest footer to a moderator.

Community practice often offloads the guest HTML one layer further: LiteSpeed Cache (LSCache) with the XF2 add-on, Nginx FastCGI cache, or Cloudflare edge. Those stacks serve a flat file and skip PHP-FPM entirely for guests, then bypass the cache for cookies that mean “this person is logged in.” That is the right model. Native $config['pageCache'] is the official in-app version of the same idea. Pick one owner. Two guest caches stacked on each other is how you spend a Saturday purging.

Redis in the wild

Official 2.3 docs in the notebook do not walk Redis. Community sources do, via SV Redis Cache and the phpredis extension on PHP 7.2+. The claimed wins: template-compiled CSS lives in RAM instead of locking xf_css_cache, and the data registry stops hitting MySQL on every request. High-availability notes from those write-ups: Redis Sentinel (primary/replica) is supported; multi-master clustering is not. If Redis flaps because of network latency, they tell you to disable Redis persistence so a write to disk does not block the PHP workers.

Treat that as community operations, not an official ACP checkbox. If you add Redis:

  1. Install phpredis. Confirm php -m lists it.
  2. Configure the adapter your add-on or Symfony docs specify. Restart PHP-FPM.
  3. Hit the homepage as a guest twice. The second hit should be cheaper.
  4. Change a style property. Confirm the front-end updates after a cache rebuild. If it does not, you are looking at a stale Redis key, not “2.3 is broken.”

Do not enable Redis for the first time during a permission rebuild. You want one variable.

CSS, JavaScript, and the icon sprite

2.3 already did the front-end work you used to buy add-ons for. Use it before you install another optimizer.

CSS is unbundled. Official 2.3 materials: core global styles are separate from page-specific templates, and HTTP/2 multiplexes them. Updating a page template no longer invalidates the global CSS cache. That is why a random edit in extra.less that should have been a page-only file is expensive — you invalidate the global sheet for a rule that only the portal needed.

JavaScript is deferred modules. Official numbers: jQuery is gone, more than 40,000 lines rewritten to ES6, about 30 KB compressed saved per request. The old monolith is split. Tooltip and form code register when the DOM actually has those nodes. Scripts in <head> use the browser defer so HTML can parse in parallel and JS runs after the DOM is ready. An add-on that injects a blocking <script src> in the header, or that still calls $('selector'), undoes this. That is an add-on bug, not a reason to turn fullJs on.

Icons are a sprite, not a webfont. The Icon Usage Analyzer walks compiled templates, modifications, phrases, and JS controllers and keeps the SVG names you actually use. Official claim: forum-index icon payload from over 400 KB to under 40 KB. Render icons with <xf:fa icon="…" /> (or the Less mixins the nodes article already documented). A raw content: "\f019" in extra.less is invisible to the analyzer.

Community threads still push Font Awesome Manager (Kirby). That advice is from 2.1 / 2.2, when the board loaded the entire Pro family (thousands of glyphs, megabytes). On 2.3, start with the stock analyzer and a cache rebuild. Add a third-party subsetter only if you have measured that you still ship a webfont. The upgrade article said the same thing. Do not install FAM “because a 2021 checklist said so.”

extra.less that stays cheap

Community style practice that the official notebook does not spell out, but that matches how 2.3 compiles CSS:

  • Leave the master style untouched. Vendor theme as a child. Your changes in a child of the vendor. Same rule as every other style article on this site.
  • Put experiments in extra.less. When that file becomes a novel, split it: <xf:include template="your_custom_file.less" />.
  • For add-on CSS that only belongs on one screen, put <xf:css src="your_css.less" /> at the top of that template so the sheet is not in the global bundle.
  • Host typefaces locally. A remote Google Fonts request is a DNS lookup plus a render-blocking CSS file. Official 2.3 already self-hosts its UI fonts. Your child style should too.

A huge extra.less is not, by itself, in the community sources as a named anti-pattern. An unsplit global sheet that invalidates on every tweak is. Act accordingly.

Images, WebP, and the proxy

Official 2.3 image controls are the cheapest LCP win you still have if you have not flipped them.

WebP conversion. When enabled, new JPEG, PNG, and BMP uploads become WebP. Official materials claim about 52.5% less attachment storage once you also rebuild existing files. Treat the percentage as a direction, not a promise for your screenshot dump. Rebuild from Tools (cache / rebuild jobs) or the CLI. Official warning: old PHP + GD has known image-processing bugs. Do this after the board is stable, not in the same hour as a schema upgrade — the upgrade article already sequenced that.

Client-side resize. The attachment manager can shrink the image in the browser with HTML5 before upload. Official reason: less server CPU, less visitor bandwidth. Turn it on. Members with 12 MP phone photos will not notice. Your data/attachments directory will.

Maximum pixel count. $config['maxImageResizePixelCount'] defaults to 20,000,000. Images above that are not resized and may be rejected. Official note: raising it to 48,000,000 lets the memory engine handle larger sources. Raising it also lets one upload spike PHP memory. Raise it because a photographer community needs it, not because one member complained once.

Image proxy. $config['proxyUrlFormat'] = 'proxy.php?{type}={url}&hash={hash}' is the official hook. XenForo’s HTTP client fetches remote images so your pages do not hotlink (and so mixed-content https pages do not break). The proxy is a privacy and reliability feature. It is also a cache of other people’s bytes on your disk. If you allow untrusted BB-code images, watch the proxy store.

Thumbnail sizes and native lazy-loading are not in the official notebook we queried. Do not invent ACP labels for them. Give images width and height in templates you control so CLS stays down. If you add a lazy-load add-on, community sources warn it fights third-party portals and sliders (XenPorta 2 is the named example): the slider initializes before the image exists, or never initializes. Test the portal first screen after any lazy-load change.

Cloudflare and the CDN

Community consensus is narrower than the marketing page.

Do cache static assets at the edge. That includes compiled CSS. Community write-ups tell you to put a Page Rule (or Cache Rule) on css.php so the dynamically built stylesheet is not a PHP hit on every guest. Confirm the rule matches your board URL, including index.php?css.php vs a rewritten /css.php if you use friendly URLs.

Do not Auto Minify HTML/CSS/JS if you already run an application-layer optimizer (XFOptimize is the named example). Double-minify breaks script compilation. XenForo 2.3 already ships deferred, modular JS and compiled Less. A CDN that rewrites those files is not “free performance.” It is a second compiler you do not control.

Signed Exchanges (SXG) at Cloudflare are mentioned in community sources as a way to pre-warm Googlebot. That is optional edge trivia. It is not a substitute for WebP and a guest cache.

Rocket Loader is not in the notebook sources. Skip it as a recommendation. If a host dashboard offers a “speed up JavaScript” toggle that reorders scripts, treat it like Auto Minify: turn it off, re-measure.

Purge the CDN after a style change or a css.php rule change. A guest who still sees last week’s sheet is not a XenForo bug.

Database: InnoDB, query cache, permission math

A fast front-end on a locked InnoDB log is still a slow forum.

Community database practice for XenForo:

Setting Community consensus Why
Storage engine InnoDB for almost everything Table-level MyISAM locks on write
Stock MyISAM leftovers xf_search_index, xf_session, xf_session_admin, xf_session_install Core still uses MyISAM there on a default install
xf_search_index on a VPS under 4 GB RAM Leave MyISAM InnoDB full-text wants more memory than you have
innodb_buffer_pool_size 50–75% of RAM on a dedicated DB box, or table size + 20% if the DB is smaller than RAM Hottest InnoDB pages stay in memory
innodb_flush_log_at_trx_commit 1 for durability; 2 or 0 only during a known write storm (upgrade, mass import) 1 fsyncs every commit
innodb_flush_method O_DIRECT Avoids double-buffering through the OS page cache
query_cache_type 0 (off) Query cache locks on write; a busy forum is all writes

Those knobs live in /etc/my.cnf (or MariaDB’s drop-in directory), not in the ACP. Official 2.3 docs in the notebook do not publish them. Change them with a snapshot and a window. innodb_flush_log_at_trx_commit = 0 is a recovery conversation, not a lifestyle.

Permission rebuilds lock the board. Community sources describe the cost as multiplicative: groups × nodes. A medium board (about 80 nodes, 60 groups) can spend two to four minutes rebuilding after you change a primary group. At ~700 nodes, rebuilds hit PHP timeouts and lock tables. That is why the permissions starter map told you not to create a group for every mood. Do not edit a primary group, a node tree, and a batch of secondary promotions at 19:00 on a weekday. Schedule it. Tell staff. If the board is already slow, look at who is saving permissions before you blame Redis.

Template rebuilds on heavy styles. UI.X 2 is the named example: rebuilds can die with fail to open stream: Too many open files. Community fix: raise open_files_limit (they cite 50,000) in MySQL and PHP, or rebuild from the CLI so you are not also fighting the web-server thread limit. Do not click Rebuild caches on a complex style during peak if you have never done it on staging.

Jobs. Official: $config['jobMaxRunTime'] = 8 (seconds) before a job yields. Official warning on custom code: do not $finder->fetch() every post in the database. Use the job system in batches. The same rule applies to you in Tools: do not start “rebuild all attachments as WebP” and “rebuild search index” at the same time on production at peak. One job. Watch Tools → Cron entries if mail or counters stall — a dead cron looks like a slow site because deferred work starts riding the next visitor request.

The first 60 minutes on a slow live board

When members say it died, do not install an optimizer. Work this order. It is the community triage plus the official “do not” list.

Minutes Action Stop if…
0–10 Who is in the ACP? Abort a permission or node save. Check $config['debug'] and fullJs. Debug was on. You already won.
10–20 Is Redis / the Symfony cache actually up? php -m, Redis PING, a second guest hit. If Redis is erroring, disable persistence or fail closed to file cache so PHP stops waiting on a dead socket. Cache backend was down.
20–30 Guest cache: pageCache, LSCache, or FastCGI. Confirm logged-in users bypass it. Purge once. Guests are cheap and members are fine.
30–40 Cloudflare: is css.php cached? Is Auto Minify on? Turn Auto Minify off if anything else already minifies. Purge. The sheet or JS was double-processed.
40–50 Database: query_cache_type, a long SHOW PROCESSLIST, disk wait. Do not flip innodb_flush_log_at_trx_commit unless you know you are in a write storm. One query is the site.
50–60 Payload: run the Icon Usage Analyzer, confirm WebP is on for new uploads, confirm fonts are local. Do not rebuild every historical attachment in this hour. LCP was a 2 MB PNG logo.

If you finish the hour and the board is still slow, you have a code problem (an add-on) or a host problem (CPU steal, no OPcache, a 1 GB VPS running Elasticsearch). Community sources we queried do not name a “disable this add-on first” list. Build your own: disable the newest add-on, then anything that polls (chat, live online, ads). The 2.3 upgrade already told you to stage add-ons one by one. Use that muscle here.

ACP and config.php checklist

Work top to bottom. Tick only what you actually verified.

config.php (production)

  • $config['debug'] = false
  • $config['development']['fullJs'] = false
  • $config['enableGzip'] = true (unless the server/CDN already compresses and you measured)
  • Cache backend configured and $config['cache']['enabled'] = true
  • $config['pageCache']['enabled'] on only after RAM is allocated and a logged-in test passed
  • $config['jobMaxRunTime'] left sane (official default 8)
  • $config['maxImageResizePixelCount'] only raised if you need it
  • Cookie path/domain cover the board root
  • No ipv4CidrMatch / ipv6CidrMatch = 0

ACP

  • Setup → Options → Basic board information → Index page route points at the screen you actually want (forum list, new posts, or a portal page). A confused index is extra queries and a worse LCP candidate.
  • Appearance → Outdated templates (and outdated phrases) is empty. Old jQuery-era template modifications are INP bugs.
  • Icon Usage Analyzer has been run after the last style/add-on change; caches rebuilt
  • WebP conversion on for new uploads; historical rebuild scheduled off-peak
  • Client-side attachment resize on
  • Cron is running; Tools jobs are not piled three deep

Server and edge

  • PHP 8.3/8.4, OPcache on, required extensions present
  • InnoDB buffer pool sized; query cache off
  • Guest HTML cached once (app or web server or edge), members bypass
  • CDN caches css.php; Auto Minify off if anything else minifies
  • Fonts local; no remote webfont on the first screen
  • Permission and template rebuilds are a scheduled change, not a reflex

What this article will not do

It will not pick a host. It will not publish a Redis config.php block the official notebook did not contain. It will not promise that LiteSpeed plus Cloudflare plus native pageCache is “more faster.” One guest-cache owner. One CSS compiler. One icon pipeline — the 2.3 analyzer.

It will also not turn the homepage into a performance project. If the first screen has a video background, five sliders, and a live chat widget, the vitals are doing their job. Remove the theatre. Then measure again.

Key takeaways

  • Debug and fullJs on production are official performance bugs. Gzip is an official win. Check config.php before you buy Redis.
  • Guest page cache is the official tool that stops PHP from building the homepage for people who are not logged in. It is off by default because it needs RAM. Members must bypass it.
  • 2.3 already deferred JS, unbundled CSS, subset icons, and converted uploads to WebP. Use those. Font Awesome Manager is a 2.2-era answer.
  • Cloudflare should cache css.php. It should not Auto Minify next to an app optimizer.
  • Permission rebuilds scale with groups × nodes. Do not save a primary-group change at peak on a big tree.
  • The first hour on a slow board is: stop the ACP save, prove the cache is up, prove guests are cached, stop the CDN from rewriting JS, then look at MySQL. Add-ons come last.

A fast board is a board whose guests hit HTML, whose members hit a warm Symfony cache, and whose admins do not rebuild permissions at eight in the evening. Everything else is a measurement.