Category: Hosting

  • WordPress multisite network admin: when one dashboard controls fifty sites

    WordPress multisite network admin: when one dashboard controls fifty sites

    WordPress multisite network admin: when one dashboard controls fifty sites
    Photo by Team Nocoloco on Unsplash

    WordPress multisite lets you run dozens—or hundreds—of sites from a single installation. One database, one set of core files, one admin login that controls everything. It’s how WordPress.com hosts millions of blogs, and it’s available in every self-hosted WordPress install.

    But the network admin dashboard isn’t just a beefed-up version of the regular WordPress admin. It works differently, exposes different controls, and introduces failure modes you won’t encounter on a single site. If you’re running multiple content sites, client projects, or testing environments, understanding what network admin actually does will save you hours of troubleshooting.

    What network admin controls (and what it doesn’t)

    When you enable multisite, WordPress splits its admin interface into two layers. Each site still has its own dashboard where editors manage posts, pages, and basic settings. The network admin sits above that, accessible only to super admins, and controls infrastructure shared across all sites.

    From the network admin dashboard, you can:

    • Add, delete, archive, or mark sites as spam
    • Install plugins and themes once, then enable them selectively per site
    • Create new users with network-wide access or restrict them to specific sites
    • Configure domain mapping, so different sites answer to different domains
    • Set upload limits, storage quotas, and permitted file types globally

    You cannot directly edit another site’s content from network admin. To publish a post on Site B, you still need to switch to Site B’s dashboard. Network admin is about infrastructure and permissions, not day-to-day content operations.

    Plugin and theme activation works in two steps

    On a single WordPress site, you install a plugin and activate it. Done. On multisite, installation and activation are separate, and both require network admin access.

    First, you network-install the plugin—uploading it or pulling it from the repository. At this stage, it’s dormant. No site can use it yet. Then you either network-activate it (turning it on for every site instantly) or enable it per-site, letting individual site admins activate it themselves.

    This two-step design prevents rogue site admins from installing code network-wide. It also means you can test a plugin on one site before rolling it out. But it introduces a common mistake: activating a plugin on one site, seeing it work, then wondering why it’s missing on Site #7. You have to check both the network plugins page and each site’s plugins page to know what’s actually running.

    Themes work the same way. Network-enable a theme, and it appears in every site’s theme picker. If you don’t enable it, site admins won’t see it at all—even though the files are physically installed.

    When multisite makes sense (and when it’s overkill)

    Multisite shines when you’re running multiple sites that share the same plugin stack, user base, or design system. Examples:

    • A media company publishing five topical sites under different domains
    • An agency managing client sites from one hosting account
    • A solo operator running separate content brands that share backend infrastructure
    • A staging/production split where both environments live in one installation

    Multisite is not a good fit if your sites need fundamentally different plugins, have separate user bases with no shared logins, or require independent backups and update schedules. In those cases, separate WordPress installs—each with its own database and admin—are simpler to manage and easier to migrate or sell later.

    Multisite also complicates hosting. Not every managed WordPress host supports it. Those that do often charge more, because resource limits (disk, CPU, database queries) now apply to the entire network rather than one site. If Site #3 gets traffic-spiked and maxes out your database connection pool, every site in the network slows down.

    The non-obvious tip: use subdirectory mode unless you control DNS

    When you set up multisite, WordPress asks whether new sites should use subdomains (site2.example.com) or subdirectories (example.com/site2). Both work, but subdirectories are far easier to manage unless you already have wildcard DNS and SSL configured.

    Subdomain mode requires a wildcard DNS A record pointing *.example.com to your server, plus a wildcard SSL certificate (or a host that auto-provisions Let’s Encrypt certs per subdomain). If you don’t control DNS—say, you’re on a shared host or using a domain registrar with limited DNS editors—subdomain mode breaks. New sites won’t resolve, and you’ll chase certificate errors for hours.

    Subdirectory mode just works. No DNS changes, no SSL gymnastics. The tradeoff: URLs look less independent. But if you’re running a network for operational efficiency rather than separate brand identity, subdirectories save you a week of troubleshooting.

    WordPress multisite isn’t a feature most operators need. But if you’re managing more than three sites with overlapping workflows, it’s worth the learning curve—just expect to spend a day reading documentation before your first network goes live.

    Got a question about WordPress infrastructure or any other online-business tool? Reply to this email—I answer every one, and reader questions often become future articles.

  • WordPress CDN purge delays: what ‘instant’ invalidation really means

    WordPress CDN purge delays: what ‘instant’ invalidation really means

    WordPress CDN purge delays: what 'instant' invalidation really means
    Photo by Erik Mclean on Unsplash

    You push a WordPress post update, hit the CDN purge button, and refresh the page. The old version still loads. You check the dashboard—it says the cache cleared thirty seconds ago. So why is your reader in Singapore still seeing yesterday’s headline?

    CDN cache invalidation isn’t instant, even when providers claim sub-second purge times. What they measure and what you experience are two different things.

    What “purge time” actually measures

    When Cloudflare or BunnyCDN reports a three-second purge, they’re measuring how long it takes their control plane to mark the cached object as stale across their network. That’s an internal API call, not the moment your updated content becomes visible globally.

    Propagation delay comes from three sources:

    • Edge node sync latency: The purge command reaches regional POPs (points of presence) at different times. A node in Frankfurt might invalidate in two seconds; one in Sydney might take twelve.
    • In-flight requests: If a user’s browser already fetched the HTML but is still loading CSS and images, those assets may serve from cache even after purge completes.
    • Browser cache headers: If your origin sent Cache-Control: max-age=3600, the browser won’t even ask the CDN for an hour, regardless of purge status.

    Most CDN dashboards report control-plane purge time, not edge-node propagation time. The difference can be thirty seconds to two minutes under normal conditions.

    How WordPress caching plugins trigger purges

    Plugins like WP Rocket, W3 Total Cache, and LiteSpeed Cache hook into WordPress’s clean_post_cache action. When you update a post, they fire a purge request to your CDN’s API.

    But the plugin only knows the purge request sent—it doesn’t wait for confirmation that all edge nodes updated. If your CDN API returns a 200 status after queuing the purge, the plugin considers it done.

    This creates a false positive: the plugin shows a success message, but the purge is still propagating. If you immediately test the live URL, you might hit an edge node that hasn’t received the invalidation yet.

    Some CDNs—Cloudflare included—offer a “purge everything” option that’s faster than purging individual URLs. It’s a sledgehammer, but for time-sensitive updates (like fixing a pricing error in a sales post), it’s often the safer choice.

    Testing purge propagation across regions

    Don’t trust the dashboard timestamp. Test actual propagation using a multi-region tool or curl from different locations.

    Here’s a quick manual test using curl and a timestamp query string:

    curl -I "https://yoursite.com/post-slug/?t=$(date +%s)"

    Check the CF-Cache-Status (Cloudflare), X-Cache (BunnyCDN), or equivalent header. If it says HIT, that edge node is still serving cached content. Run this from multiple geographic locations—services like Pingdom or GTmetrix can help—to see how long full propagation actually takes.

    In practice, expect sixty to ninety seconds for global propagation on most CDNs, even when the dashboard says “purged” after five seconds.

    When purge delays break workflows

    Two scenarios make this particularly painful:

    Newsletter send timing: You schedule a post to publish at 9:00 AM, then queue a newsletter linking to it at 9:05 AM. If the CDN hasn’t fully propagated, early openers see a 404 or stale content. Add a ten-minute buffer, or use a staging URL in the newsletter and redirect after confirming the purge.

    Sponsored content updates: A sponsor asks you to correct a product name or price. You make the edit, purge, and send them the updated link. They check it thirty seconds later and still see the error. Now you’re explaining CDN propagation on a support call.

    For critical updates, use a purge-and-verify workflow: trigger the purge, wait two minutes, then test the live URL from at least two geographic regions before confirming the change is live.

    Want more infrastructure deep-dives like this? Subscribe to One Two Three Send for weekly breakdowns of the tools and systems that actually run online businesses—no fluff, just what works and what breaks.

    Reducing purge delays

    You can’t eliminate propagation time, but you can reduce uncertainty:

    • Set shorter max-age values on high-churn content (e.g., homepage, latest posts). Sixty seconds instead of 3600 means users re-check the CDN more frequently.
    • Use versioned asset URLs (e.g., style.css?v=1.2.3) instead of purging CSS and JS. WordPress plugins like WP Rocket do this automatically.
    • Enable stale-while-revalidate headers (Cache-Control: max-age=60, stale-while-revalidate=300) so the CDN serves stale content while fetching a fresh copy in the background.

    And if you’re on a budget host with inconsistent purge APIs, consider switching to a provider with better WordPress integration. BigScoots, for example, includes enterprise CDN tooling even on shared plans, with purge confirmation hooks that actually report edge-node status.

    The next time your CDN dashboard says “purged,” give it two minutes and a multi-region test before you trust it. Instant invalidation is a marketing term, not a technical reality.

  • WordPress plugin update rollbacks: how automatic reversions work

    WordPress plugin update rollbacks: how automatic reversions work

    WordPress plugin update rollbacks: how automatic reversions work
    Photo: Simpson, Thomas via Wikimedia Commons (Public domain)

    WordPress 6.3 shipped a feature most operators don’t know exists until it saves them: automatic plugin rollback after a fatal error. If a plugin update crashes your site, WordPress will—under specific conditions—revert to the previous version without you touching anything.

    That safety net isn’t as wide as it sounds. Understanding when it fires, what it catches, and what it misses will save you from assuming your site is protected when it isn’t.

    What triggers an automatic rollback

    WordPress monitors plugin updates for fatal PHP errors during a brief window immediately after activation. If a newly updated plugin throws a fatal error that would break your site, WordPress detects it during the next loopback request—a self-ping the system uses to verify the site is still responding.

    If that loopback fails, WordPress rolls the plugin back to its previous version and sends an email to the site admin address on file. The entire process happens within seconds to minutes, depending on how quickly the loopback request completes.

    Three conditions must be true for rollback to fire:

    • The plugin was updated through the WordPress admin dashboard or WP-CLI with the --defer-site-health flag
    • The fatal error occurs during plugin load or initialization—not later during a page request
    • The loopback request detects the failure before the request timeout (default: 10 seconds)

    If you update via SFTP, the rollback system never sees the change. If the plugin loads fine but crashes when a visitor hits a specific page, rollback won’t catch it. If your server is slow and the loopback times out before detecting the error, you’re on your own.

    What gets reversed, what doesn’t

    Rollback restores the plugin’s PHP files to the previous version. That’s it. Any database changes the new version made—schema migrations, new rows, updated option values—stay in place.

    This creates a mismatch problem. If version 2.0 of a plugin adds a database column and version 1.9 expects it not to exist, rolling back the code doesn’t undo the schema change. You’re now running old code against a new database structure.

    Most well-coded plugins handle this gracefully by checking for the existence of columns or tables before querying them. Poorly coded plugins assume the database structure matches the code version and break in new ways after rollback.

    Settings changes are similarly sticky. If the new version migrated your settings array to a new format, rollback won’t revert that migration. You’ll need to manually restore settings from a backup or reconfigure the plugin.

    When rollback fails silently

    Rollback depends on the loopback request succeeding. If your server blocks loopback requests—common on shared hosts with aggressive firewall rules or when using localhost SSL certificates—WordPress can’t verify the site is broken, so it won’t roll back.

    You can test whether loopbacks work by visiting Tools > Site Health in the WordPress admin. If the “Loopback request” test fails, automatic rollback won’t work either. Fixing it usually requires whitelisting your own domain in your firewall or adjusting WP_HTTP_BLOCK_EXTERNAL settings.

    Rollback also won’t fire if the fatal error occurs outside the plugin’s initialization phase. If a plugin loads successfully but crashes when you try to access its settings page, that’s a runtime error, not an initialization failure. WordPress considers the update successful because the site didn’t break immediately.

    Manual rollback as fallback

    Even when automatic rollback works, it’s worth knowing how to roll back manually. WordPress doesn’t keep old plugin versions on your server—it deletes them after update. To manually revert, you’ll need to download the previous version from the WordPress.org plugin repository.

    Visit wordpress.org/plugins/[plugin-slug]/advanced/ to access the developer view, which lists all previous versions. Download the version you need, delete the current plugin folder via SFTP or your host’s file manager, and upload the old version. Reactivate if necessary.

    If you’re on a managed WordPress host, check whether they offer automatic snapshots before updates. Kinsta, WP Engine, and similar hosts take filesystem snapshots before applying updates, letting you restore the entire plugin folder—and sometimes the database state—from before the update.

    For operators managing multiple sites, a staging environment is the better insurance. Test plugin updates on staging first. If they break, your production site never sees the bad code. If automatic rollback is your only safety net, you’re relying on a system that only catches a subset of failures.

    One Two Three Send covers WordPress operations, hosting, and the tools solo operators use to keep sites running. Subscribe for one operator-focused article every day.

  • WordPress REST API rate limiting: how plugins enforce request caps

    WordPress REST API rate limiting: how plugins enforce request caps

    WordPress REST API rate limiting: how plugins enforce request caps
    Photo by Stephen Phillips – Hostreviews.co.uk on Unsplash

    If you run a WordPress site with any meaningful traffic—or if you’ve built automation that hits your own API—you’ve probably seen a 429 error at some point. The WordPress REST API doesn’t enforce rate limits by default, but most production sites end up with them anyway, often without realizing it.

    The limits come from security plugins, caching layers, or server-level middleware. They’re rarely documented in dashboards, and they trigger differently depending on whether the request is authenticated, what endpoint you’re hitting, and whether the plugin treats logged-in users differently from unauthenticated bots.

    Here’s how the most common rate-limiting mechanisms actually work, and what to check when legitimate requests start getting blocked.

    Where rate limits come from

    Core WordPress doesn’t rate-limit the REST API. It’s open by default. But most hosting providers and security plugins layer restrictions on top:

    • Security plugins like Wordfence, iThemes Security, and Sucuri impose per-IP request caps, usually between 10 and 60 requests per minute depending on the endpoint.
    • Hosting firewalls (Cloudflare, server-level mod_security rules, or host-specific WAFs) apply blanket limits to all /wp-json/ traffic, often as low as 5 requests per second.
    • Caching plugins (WP Rocket, LiteSpeed Cache) sometimes block repeat API calls as part of bot-protection rules, especially if the requests bypass the cache entirely.

    The problem: these tools don’t coordinate. A single legitimate automation script can trip multiple rate limiters at once, and the error messages rarely tell you which layer rejected the request.

    How plugins enforce caps

    Most WordPress security plugins use one of two methods:

    IP-based throttling: The plugin logs request timestamps per IP address. If you exceed the cap within the time window, it returns a 429 response. This breaks multi-user environments where users share an IP (office networks, VPNs). It also penalizes API clients that batch requests from a single origin.

    Endpoint-specific rules: Some plugins treat /wp-json/wp/v2/posts differently from /wp-json/custom-namespace/endpoint. Public read endpoints often get stricter limits than authenticated write endpoints, under the assumption that bots hammer public routes more aggressively. Wordfence, for example, applies a default 10-request-per-minute cap to unauthenticated API traffic, but allows 60 for logged-in users.

    The gotcha: if your automation uses application passwords or OAuth tokens, the plugin may still treat it as unauthenticated if the token isn’t passed correctly in the Authorization header. That drops you into the stricter bucket.

    What the 429 response doesn’t tell you

    When you hit a rate limit, WordPress returns 429 Too Many Requests. Sometimes you get a Retry-After header; often you don’t. The response body rarely names the plugin or layer that blocked you.

    To diagnose:

    • Check server access logs for the exact timestamp of the 429. If the response came from Apache or Nginx (not PHP), it’s a server-level firewall rule, not a plugin.
    • Temporarily disable security plugins one at a time. If the 429 disappears, you’ve found the source.
    • Look for rate-limit settings in your security plugin’s advanced options. They’re rarely on the main dashboard—Wordfence buries them under “Firewall Options,” iThemes under “Advanced Settings.”

    If you’re using Cloudflare, check the Security Events log. Cloudflare’s “Rate Limiting” rules fire independently of WordPress, and they don’t show up in your WordPress error logs at all.

    When to raise limits (and when not to)

    If you’re hitting limits with your own automation, the fix is straightforward: whitelist your script’s IP or user agent in the security plugin’s settings. Most plugins let you exempt specific IPs or authenticated users from throttling.

    If public traffic is hitting limits, don’t just raise the cap. Rate limits exist to block credential-stuffing attacks and brute-force POST spam. Instead:

    • Cache aggressively. If bots are hammering /wp-json/wp/v2/posts, serve that response from Redis or a CDN edge. The request never hits PHP, so it doesn’t count against your rate limit.
    • Require authentication for write endpoints. If your API accepts user-submitted data, force clients to authenticate. Unauthenticated POST requests should return 401, not 200.
    • Monitor which endpoints get hit. If 90% of your API traffic goes to a single route, that’s either a bot or a misconfigured frontend polling too frequently. Fix the source, not the symptom.

    Testing your own limits

    Most operators don’t know their rate limits until they hit them in production. To test:

    Use a simple loop to hit your API 100 times in 60 seconds. Start with an unauthenticated GET request to /wp-json/wp/v2/posts. Note when the first 429 appears, then repeat with an authenticated request using an application password.

    If the limits differ, your plugin treats auth and non-auth traffic separately. If they’re the same, you’re hitting a server-level cap that doesn’t distinguish between the two.

    Most hosting providers don’t advertise their API rate limits. If you’re on managed WordPress hosting, check the knowledge base or open a ticket. The limit is usually there—it’s just not on the pricing page.

    Want more infrastructure breakdowns like this? Subscribe to One Two Three Send—we dig into the mechanics other newsletters skip.

  • WordPress page builder bloat: what 3MB of inline CSS actually does

    WordPress page builder bloat: what 3MB of inline CSS actually does

    WordPress page builder bloat: what 3MB of inline CSS actually does
    Photo by Team Nocoloco on Unsplash

    Open the source code of a site built with Elementor, Divi, or WPBakery and you’ll often find 2,000–4,000 lines of CSS sitting inline in the <head>. Not linked. Not cached. Just dropped into the HTML on every single page load.

    That’s not a configuration mistake. It’s how most WordPress page builders work by default. And for solo operators running content sites, it’s one of the biggest hidden performance taxes you’re paying.

    What inline CSS actually costs

    A typical Elementor page with moderate styling generates around 250–400 KB of inline CSS. Divi can hit 500 KB. WPBakery sits somewhere in between. None of that is cached by the browser the way an external stylesheet would be.

    The performance hit shows up in three places:

    • Time to First Byte (TTFB): WordPress has to generate all that CSS server-side before it can send the page. On shared hosting or underpowered VPS instances, that adds 200–600ms.
    • Render blocking: Browsers can’t start painting the page until they’ve parsed all the CSS in the <head>. Inline styles delay First Contentful Paint (FCP) by forcing sequential processing.
    • Bandwidth waste: Every page view re-downloads styles that could have been cached once and reused across the entire site.

    Run a Lighthouse test on a page builder site and you’ll see the same warnings: “Reduce unused CSS,” “Eliminate render-blocking resources,” “Reduce server response times.” All symptoms of the same root cause.

    Why builders do this (and when it’s justified)

    Page builders inject inline CSS because it’s the only way to guarantee per-page design flexibility without managing a complex cascade of stylesheets. If every page can have unique column widths, custom font weights, and individual color overrides, bundling all possible styles into one external file doesn’t work—you’d ship megabytes of unused rules.

    For marketing sites, landing pages, or portfolio sites where design variety matters more than repeat-visitor performance, that trade-off makes sense. A freelancer showcasing client work doesn’t care if every project page regenerates its CSS—visitors rarely see more than one.

    But for content-driven businesses—blogs, newsletters, membership sites—the math flips. Your readers visit multiple pages per session. They come back weekly. And you’re competing on page speed in both Google’s Core Web Vitals rankings and reader patience.

    The fix: CSS print method and selective loading

    Most page builders have a setting buried in performance options called something like “CSS Print Method” or “CSS Loading Method.” The default is usually “Internal” or “Inline.” Switch it to “External” and the builder will write styles to a static file instead.

    In Elementor, it’s under Elementor → Settings → Features → Optimized CSS Loading. Enable it, clear your cache, and check the source again. You’ll see a <link> tag instead of a <style> block. That file gets cached by the browser and reused across every page visit.

    Divi has a similar toggle under Divi → Theme Options → Builder → Advanced → Static CSS File Generation. WPBakery requires a plugin or manual filter hook, but the principle is identical.

    One gotcha: external CSS files can break if your caching plugin and your page builder both try to manage them. If you enable external CSS and suddenly see unstyled pages, check for cache conflicts. Purge everything, disable asset optimization in your caching plugin temporarily, and re-test.

    When to skip the builder entirely

    If your site is 80% blog posts and 20% custom landing pages, you don’t need a page builder site-wide. Use the default block editor (Gutenberg) for posts and only activate the builder on pages where you need layout control.

    Most builders let you disable their CSS output on post types you’re not using. In Elementor, go to Settings → General and uncheck “Post” under “Post Types.” Your blog posts shed hundreds of kilobytes instantly.

    For operators running membership content, course platforms, or newsletter archives, this is the fastest performance win available. A plain-text post with a featured image and a couple of headings doesn’t need a visual builder. It needs fast delivery and readable typography.

    If you’re launching a new site in 2026 and content is the core product, start with a lightweight theme like GeneratePress or Kadence and only add a page builder if you hit a layout you can’t solve with blocks. You’ll save yourself months of performance firefighting later.

    Got a page builder performance question or a caching conflict you can’t solve? Hit reply—we’re collecting builder/host combinations that reliably break, and we’ll cover them in a future piece.

  • WordPress database query logs: how to read slow_query_log

    WordPress database query logs: how to read slow_query_log

    WordPress database query logs: how to read slow_query_log
    Photo: Will (Wiki Ed) via Wikimedia Commons (CC BY-SA 4.0)

    Most WordPress performance issues trace back to database queries. A plugin runs a suboptimal SELECT, your homepage load time jumps from 800ms to 4.2 seconds, and you’re left guessing which of your 23 active plugins is responsible.

    MySQL’s slow_query_log is the diagnostic tool that ends the guessing. It records every query that exceeds a time threshold you set—usually one or two seconds. The log tells you exactly which SQL statement ran, how long it took, and which rows it examined.

    Here’s how to turn it on, read the output, and use it to fix the query that’s killing your site speed.

    Enabling slow_query_log on your WordPress host

    Most managed WordPress hosts disable direct my.cnf access, but many expose slow query logging through their dashboard. BigScoots, for example, lets you toggle it on via cPanel’s MySQL configuration panel. If you’re on a VPS or dedicated server, you’ll edit the MySQL config file directly.

    Add these lines to /etc/mysql/my.cnf (or /etc/my.cnf depending on your distro):

    slow_query_log = 1
    slow_query_log_file = /var/log/mysql/slow-query.log
    long_query_time = 1

    The long_query_time value is in seconds. Setting it to 1 captures anything longer than one second. For high-traffic sites, you might start at 2 to reduce noise.

    Restart MySQL: sudo systemctl restart mysql

    Queries now log to /var/log/mysql/slow-query.log. If the file doesn’t exist, MySQL will create it on the first slow query.

    Reading the log: what the output actually means

    Open the log file. Each slow query entry looks like this:

    # Time: 2026-08-30T14:22:35.442891Z
    # User@Host: wpuser[wpuser] @ localhost []
    # Query_time: 3.204571 Lock_time: 0.000312 Rows_sent: 1847 Rows_examined: 124503
    SELECT * FROM wp_posts WHERE post_status = 'publish' ORDER BY post_date DESC;

    Here’s what matters:

    • Query_time: Total execution time in seconds. This query took 3.2 seconds.
    • Lock_time: Time waiting for table locks. Usually negligible unless you’re running MyISAM tables (you shouldn’t be).
    • Rows_sent: How many rows the query returned. Here, 1,847.
    • Rows_examined: How many rows MySQL scanned to build that result. Here, 124,503. That’s a 67:1 examination-to-return ratio—terrible efficiency.

    The query itself follows. In this case, it’s a broad SELECT * with no LIMIT clause, scanning every published post.

    Identifying the plugin or theme responsible

    The slow query log shows the SQL, but not which PHP file triggered it. To trace that, enable WordPress’s SAVEQUERIES constant in wp-config.php:

    define('SAVEQUERIES', true);

    Install the Query Monitor plugin. It cross-references the slow queries with the calling function, showing you the exact plugin or theme file responsible.

    In most cases, you’ll find one of three culprits:

    • A poorly-coded custom query in a theme’s functions.php
    • An analytics or “related posts” plugin running uncached lookups on every page load
    • A WooCommerce or membership plugin querying order or user meta without indexes

    Once you’ve identified the source, you have three options: optimize the query, cache the result, or replace the plugin.

    One non-obvious tip: check Rows_examined even for fast queries

    A query might finish in 0.8 seconds—just under your long_query_time threshold—but still examine 200,000 rows to return 12. That’s inefficient, and it will degrade as your database grows.

    Manually review your site’s most-used queries with Query Monitor’s “Queries by Component” view, sorted by row examination count. If any query examines more than 10x the rows it returns, add an index or rewrite it.

    Run EXPLAIN on the suspect query in phpMyAdmin or the MySQL command line to see which indexes MySQL is using (or ignoring). If the “type” column shows “ALL,” you’re doing a full table scan—add an index on the columns in your WHERE or ORDER BY clause.

    Want more infrastructure deep-dives like this? Subscribe to One Two Three Send—every article covers one specific tool, feature, or workflow decision for solo operators running content businesses.

  • WordPress lazy loading delays images differently on mobile vs desktop

    WordPress lazy loading delays images differently on mobile vs desktop

    WordPress lazy loading delays images differently on mobile vs desktop
    Photo by Stephen Phillips – Hostreviews.co.uk on Unsplash

    WordPress enabled native lazy loading by default in 2020, and most operators never think about it again. The browser handles it, images load as users scroll, and Core Web Vitals improve. Simple.

    Except lazy loading doesn’t work the same way on mobile and desktop. The threshold that triggers an image to start loading—called the “distance-from-viewport” threshold—varies by browser, device type, and connection speed. That inconsistency affects how fast your site feels, even when the technical metrics look fine.

    How lazy loading thresholds actually work

    When WordPress adds loading="lazy" to an image, it’s telling the browser: don’t load this until the user is about to see it. But “about to see it” isn’t a fixed distance.

    Chrome and Edge use a threshold of roughly 1250 pixels on desktop with a fast connection. That means an image starts loading when it’s still more than a full screen away. On mobile, that threshold drops to around 2500 pixels—proportionally much larger relative to viewport height, but still about two to three scroll lengths.

    On slower connections (defined by the browser’s effectiveType API), those thresholds shrink. Chrome drops to around 2500 pixels on desktop and 1250 pixels on mobile when it detects 3G speeds.

    Firefox uses similar logic but calculates thresholds as a multiplier of viewport height rather than fixed pixels. Safari’s implementation is more conservative across the board.

    The result: the same page can trigger image loads at wildly different scroll positions depending on device and connection. A hero image that loads instantly on desktop might not even start fetching until a mobile user scrolls halfway down the page.

    Where this breaks perceived performance

    Lazy loading improves Largest Contentful Paint (LCP) and Total Blocking Time by deferring offscreen images. But it can hurt perceived speed if critical above-the-fold images get lazy-loaded by mistake.

    WordPress excludes the first image in the content from lazy loading, but that heuristic fails when:

    • Your hero image is a background image in CSS, not an <img> tag
    • Your layout uses a sidebar, and the “first” image is a small thumbnail in the sidebar, not the main content image
    • You’re using a page builder that injects images via JavaScript after initial render

    On mobile, where thresholds are proportionally larger, users scroll faster and notice the delay more. An image that starts loading at 2500 pixels might not finish rendering before the user reaches it, especially on slower devices or connections.

    This is why some sites feel faster on desktop even when mobile scores higher in Lighthouse. The lazy loading threshold mismatch creates a perception gap that metrics don’t fully capture.

    How to adjust lazy loading behavior

    You can’t directly control browser thresholds, but you can control which images get the loading="lazy" attribute.

    The simplest fix: remove lazy loading from above-the-fold images. WordPress provides a filter to exclude specific images:

    add_filter( 'wp_lazy_loading_enabled', '__return_false' );

    That disables it globally, which is overkill. Instead, target specific images by hooking into wp_get_attachment_image_attributes and removing the loading attribute for images in the first few blocks or above a certain position in the DOM.

    Most page builders and themes offer a “disable lazy loading” toggle for hero sections. Use it. The performance cost of eagerly loading one or two large images is trivial compared to the perceived-speed hit of lazy-loading them incorrectly.

    For mobile-specific tuning, consider using the fetchpriority="high" attribute on your primary hero image. It’s supported in Chrome, Edge, and Safari, and it tells the browser to prioritize that image even if lazy loading would otherwise delay it. WordPress doesn’t add this by default, but you can inject it via the same attribute filter.

    Test with real throttling, not just Lighthouse

    Lighthouse simulates a slow connection, but it doesn’t show you the scroll-and-wait behavior real users experience. Open Chrome DevTools, switch to the Network tab, and throttle to “Slow 3G.” Then actually scroll your site on a mobile viewport.

    Watch where images start loading. If they’re popping in after you’ve scrolled past them, your thresholds are too conservative or your images are too large. If they’re all loading at once on page load, you’re not benefiting from lazy loading at all.

    Run the same test on desktop. The behavior should be noticeably different. If it’s not, you might have a plugin overriding WordPress’s native implementation with a JavaScript-based lazy loader—those usually use fixed thresholds regardless of device.

    Most solo operators optimize for Lighthouse scores and call it done. But perceived speed matters more than any single metric, and lazy loading thresholds are one of the few variables that affect perception without showing up in reports.

    Reply with the laziest-loading image you’ve ever encountered. We’re collecting examples for a follow-up piece on edge cases that break WordPress’s heuristics.

  • WordPress page caching: when dynamic content breaks static delivery

    WordPress page caching: when dynamic content breaks static delivery

    WordPress page caching: when dynamic content breaks static delivery
    Photo: Rami via Wikimedia Commons (Public domain)

    Page caching is one of the fastest ways to speed up a WordPress site. Instead of rebuilding every page on every visit, the server delivers a static HTML snapshot. For content-driven sites, it’s a performance multiplier—until it breaks something you didn’t realize was dynamic.

    The promise is simple: cache the page once, serve it to everyone. The problem is that “everyone” includes logged-in users, people in different time zones, and visitors who should see personalized content. When you turn on full-page caching without understanding what gets frozen, you end up serving stale data to the wrong people.

    What page caching actually does

    Most caching plugins—WP Rocket, LiteSpeed Cache, W3 Total Cache—generate a static HTML file the first time someone visits a page. Every subsequent visitor gets that file until it expires or you manually clear the cache. The PHP engine never runs. The database never gets queried. It’s fast because it skips WordPress entirely.

    That works fine for a blog post that doesn’t change. It breaks the moment you add anything that should look different for different visitors: a “Welcome back, [Name]” message, a shopping cart count, a members-only section, a countdown timer, or even a “currently online” widget.

    The static HTML file doesn’t know who’s visiting. It shows the same cached snapshot to everyone—logged in or not, subscriber or guest, New York or Tokyo.

    What breaks first

    The most common casualties are widgets and sidebar elements that rely on server-side state. A “recent comments” block might show the same five comments for hours, even as new ones come in. A “popular posts” widget freezes at whatever was popular when the cache was built. If you’re running ads or affiliate content that rotates server-side, the same banner gets served to everyone until the cache clears.

    Logged-in users see the logged-out version of the page unless you explicitly exclude them from caching. That means no admin bar, no edit links, no personalized menus. If you’re running a membership site or a course platform, this is a deal-breaker. Members land on a page that tells them to log in, even though they already are.

    Checkout pages, account dashboards, and cart views also break under full-page caching. Most plugins auto-exclude /cart/ and /checkout/ paths, but if you’re using a custom URL structure or a non-standard plugin, you need to add exclusions manually.

    How to cache without breaking dynamic content

    The cleanest fix is to separate static and dynamic elements. Cache the page structure—header, footer, main content—and load personalized pieces via JavaScript after the page renders. Most modern membership plugins and ecommerce platforms do this by default: the page loads instantly from cache, then an AJAX call fetches the cart count or user name and injects it into the DOM.

    If you’re building custom features, use the same pattern. Cache the full page, but add a data-user-id attribute to any personalized container and populate it client-side with a lightweight API call. Keep that endpoint uncached—exclude /wp-json/ routes or use a dedicated AJAX handler.

    For logged-in users, most caching plugins offer a “don’t cache for logged-in users” toggle. Enable it if your site has frequent admin activity or if subscribers expect personalized views. The trade-off is that logged-in traffic bypasses the cache entirely, so page speed drops for those visitors. If you have a small team and most traffic is anonymous, this is fine. If half your visitors are logged in, you’re losing most of the performance benefit.

    Time-sensitive content—countdowns, limited offers, live event notices—should either be excluded from caching or rendered client-side. A cached page with a countdown timer will show the same time to everyone until the cache expires, which defeats the urgency. Use JavaScript to calculate the time difference in the browser, or exclude the page from caching and accept the performance hit.

    Testing what actually gets cached

    The easiest way to catch caching issues before they go live: visit your site in an incognito window, clear the cache, reload the page, then log in and reload again. If the logged-in view looks identical to the logged-out view, something’s wrong. Check the page source—if you see on a logged-in page, you need to adjust your exclusion rules.

    Most caching plugins log which pages get cached and which get bypassed. WP Rocket and LiteSpeed Cache both show cache hit/miss stats in their dashboards. If a checkout page shows a cache hit, you know it’s misconfigured.

    For membership sites, test with a subscriber account. Log in, navigate to a members-only page, then open the same URL in a private window. If the private window shows the member content, the page was cached while you were logged in and is now being served to everyone. Add that path to your exclusion list immediately.

    Page caching is worth the setup cost—most WordPress sites see 40–60% faster load times once it’s configured correctly. But if you ship personalized content, ecommerce, or logged-in experiences, the default settings will break something. Test every dynamic element, exclude what needs to stay fresh, and move the rest to client-side rendering.

    Got a caching setup that works for your WordPress site? Reply and let us know which plugin you’re using and how you handle logged-in users—we’ll feature the best setups in a future roundup.

  • WordPress REST API authentication: tokens vs. cookies vs. nonces

    WordPress REST API authentication: tokens vs. cookies vs. nonces

    WordPress REST API authentication: tokens vs. cookies vs. nonces
    Photo: Bilal Elmoussaoui and Authenticator contributors via Wikimedia Commons (GPLv3)

    If you’re building custom workflows on top of WordPress—feeding published posts into a social scheduler, syncing custom fields to an external CRM, or triggering email sends when a post goes live—you’re probably using the REST API. And authentication is where most operators hit a wall.

    WordPress offers three main authentication methods for REST API requests: application passwords (tokens), cookie authentication, and nonces. Each behaves differently, and choosing the wrong one means your automation fails silently or exposes your site to unnecessary risk.

    Application passwords: the safe default for external tools

    Application passwords were added to WordPress core in version 5.6. They’re revocable tokens tied to a specific user account, designed for external applications that need programmatic access without exposing your actual login password.

    When you generate an application password from your user profile, WordPress creates a 24-character token. You pass it via HTTP Basic Auth in every API request. If your workflow tool gets compromised or you stop using it, you revoke the token—your main password stays intact.

    This is the right choice for any tool that lives outside WordPress: Zapier workflows, custom Node.js scripts, Python automation, or third-party SaaS that needs to read or write data. The token can’t be used to log into the WordPress admin, only to authenticate API calls.

    Non-obvious gotcha: Application passwords only work over HTTPS. If your staging site uses HTTP, authentication will fail with a vague 401 error. WordPress blocks the feature entirely on non-encrypted connections.

    Cookie authentication: built-in, but brittle for automation

    Cookie authentication is what WordPress uses when you’re logged into the admin and browse the site. Your session cookie proves who you are. The REST API respects that cookie, so any JavaScript running on your own site—inside the WordPress admin or on the front end—can make authenticated requests without extra setup.

    This works fine for plugins or custom admin dashboards. But it’s unreliable for external automation. Cookies expire. They don’t travel well across domains. And if you’re running a headless setup or calling the API from a server, cookies don’t exist.

    Cookie-based requests also require a valid nonce for any write operation (POST, PUT, DELETE). The nonce is a time-limited token WordPress generates to prevent cross-site request forgery. It’s automatically included in admin-area JavaScript via wp_localize_script(), but if you’re building a custom front-end interface, you need to fetch and attach it manually.

    When to use it: Custom admin-area tools, React-based dashboards embedded in WordPress, or AJAX requests from logged-in users on the front end. Not for cron jobs, external services, or anything that runs without an active browser session.

    Nonces: not authentication, just anti-forgery

    Nonces are often confused with authentication, but they’re not. A nonce proves a request came from your site—not from a malicious third-party form. It doesn’t prove who sent the request; it just checks that the request originated from a legitimate WordPress-generated page.

    Nonces expire after 24 hours by default (technically 12–24 hours depending on when they were generated). If your automation fetches a nonce and then waits two days to use it, the request fails.

    You generate a nonce in PHP with wp_create_nonce('action-name') and validate it with wp_verify_nonce(). The REST API checks nonces automatically when you use cookie authentication, but only for destructive operations. Read-only GET requests don’t need one.

    Common mistake: Hardcoding a nonce into a JavaScript file. It expires, and your AJAX calls start failing silently. Always generate nonces dynamically and pass them to your script at page load.

    Which one to use

    If you’re calling the WordPress REST API from outside WordPress—Zapier, a headless front end, a Python script, a mobile app—use application passwords. Generate one per tool, label it clearly, and revoke it when you’re done.

    If you’re building a feature inside WordPress—a custom admin page, a front-end dashboard for logged-in users—use cookie authentication. WordPress handles the session for you. Just remember to include a nonce for write operations.

    If you’re passing data between WordPress and an external service that you control, consider setting up a custom endpoint with a shared secret instead of relying on user-based authentication. Store the secret in an environment variable, check it in your endpoint logic, and skip the user-permission overhead entirely.

    Most authentication failures in WordPress automations come from mixing these methods or assuming cookies work outside the browser. Pick the method that matches where your code runs, and half your API errors disappear.

    Got a WordPress automation question? Reply to this email—we cover one reader question every Sunday.

  • WordPress multisite subdomain DNS: how wildcard records actually work

    WordPress multisite in subdomain mode lets you spin up site1.example.com, site2.example.com, and so on—all from a single WordPress install. It’s powerful for niche site portfolios, client networks, or SaaS-style content platforms. But the DNS setup trips up even experienced operators, especially when subdomains don’t resolve or SSL certificates fail to provision.

    Here’s how the wildcard DNS record actually works, what propagation looks like in practice, and the edge cases that break automated SSL issuance.

    What the wildcard A record does

    When you configure WordPress multisite in subdomain mode, you add a single DNS record at your registrar or DNS provider:

    *.example.com A 203.0.113.45

    That asterisk is a wildcard. It tells DNS resolvers: “any subdomain that doesn’t have its own explicit record should point to this IP address.” So blog.example.com, shop.example.com, and anythingyouwant.example.com all resolve to the same server—your WordPress host.

    The WordPress application then inspects the Host header in each HTTP request and serves the correct site from its internal database. The DNS layer doesn’t know or care which subdomains exist; it just routes everything to the same place.

    Propagation timing and the root domain exception

    Wildcard DNS propagates like any other record—typically within minutes to a few hours, depending on TTL and resolver caching. But two gotchas appear frequently:

    The root domain doesn’t match the wildcard. If you have an existing A record for example.com pointing to a different IP (say, a marketing site on a separate host), that takes precedence. The wildcard only catches subdomains. If you want example.com itself to serve a multisite network site, you need a separate A record for the root, and it must point to the same IP as the wildcard.

    Explicit subdomain records override the wildcard. If you previously set up mail.example.com A 198.51.100.10 for an email service, that record wins. The wildcard only applies when no more-specific record exists. Audit your DNS zone file before enabling multisite—old staging subdomains or forgotten services can create confusing “site not found” errors.

    SSL certificate provisioning and wildcard complications

    Most managed WordPress hosts and CDNs (Cloudflare, Kinsta, WP Engine) offer automatic Let’s Encrypt SSL. But wildcard certificates require DNS-01 challenge validation, not the simpler HTTP-01 method.

    Here’s what that means in practice:

    • Single-site certificates use HTTP-01: Let’s Encrypt places a file at example.com/.well-known/acme-challenge/token, retrieves it, and issues the cert. Takes seconds.
    • Wildcard certificates use DNS-01: Let’s Encrypt asks you to create a TXT record at _acme-challenge.example.com, waits for propagation, validates it, then issues. This requires API access to your DNS provider, which not all hosts support automatically.

    If your host doesn’t support wildcard SSL automation, you have two options:

    1. Manually provision wildcard certs every 90 days (painful).
    2. Use a reverse proxy like Cloudflare in front of WordPress, letting Cloudflare handle wildcard SSL termination. Traffic flows: visitor → Cloudflare (SSL) → origin server (can be HTTP or a Cloudflare-issued origin cert).

    Cloudflare’s free tier includes wildcard SSL and works well for multisite operators who don’t need enterprise SLA guarantees. Just ensure SSL/TLS mode is set to “Full” or “Full (strict)”—”Flexible” mode (Cloudflare-to-visitor encrypted, Cloudflare-to-origin unencrypted) creates mixed-content warnings and breaks WordPress admin over HTTPS.

    When new subsites don’t resolve immediately

    You create a new subsite in WordPress, visit newsite.example.com, and get a DNS error. The wildcard’s already in place—what’s wrong?

    Two common causes:

    Local DNS cache. Your machine or router cached a previous NXDOMAIN (non-existent domain) response. Flush your local DNS cache (sudo dscacheutil -flushcache on macOS, ipconfig /flushdns on Windows) or wait 5–15 minutes.

    CAA records blocking SSL issuance. If you have a CAA record at the root domain restricting which certificate authorities can issue certs (e.g., example.com CAA 0 issue "letsencrypt.org"), and your host uses a different CA or expects wildcard issuance, the cert request fails silently. Check your DNS zone for CAA records if SSL won’t provision for new subsites.

    One non-obvious tip: use a staging wildcard on a separate domain

    If you’re testing multisite before going live, don’t use a subdomain of your production domain—use a completely separate domain or a .test suffix with local /etc/hosts entries. Why? Because once you add the wildcard A record to your live domain, every possible subdomain resolves, including ones you haven’t created yet. That can expose staging sites to search engines or curious visitors poking around common subdomain names like staging.example.com or dev.example.com.

    A safer pattern: register example-staging.com, apply the wildcard there, and test your network in isolation. When ready, migrate to the production domain with confidence that DNS and SSL won’t surprise you.

    Got a WordPress multisite setup question? Hit reply—we’d love to feature your scenario in a future Q&A piece.