Category: Hosting

  • WordPress post scheduler cron: how it works and when it fails

    WordPress post scheduling feels like magic—until a post doesn’t publish on time. You set a future date, hit schedule, and assume the post will go live at 9:00 AM sharp. Sometimes it does. Sometimes it publishes three minutes late. Sometimes it doesn’t publish at all until you manually refresh the site.

    The reason is simple: WordPress doesn’t use real cron. It fakes it. And for solo operators running lean sites with inconsistent traffic, that fake cron system breaks more often than you’d expect.

    How WordPress scheduling actually works

    When you schedule a post, WordPress stores the future publish time in the database and registers a “cron event” tied to that timestamp. But WordPress cron isn’t a server-level scheduled task—it’s a PHP script that runs only when someone visits your site.

    Every time a page loads, WordPress checks if any cron events are overdue. If one is, it spawns a background HTTP request to wp-cron.php, which processes the event queue. That queue includes scheduled posts, plugin tasks, update checks, and anything else hooked into the cron system.

    This approach works fine for high-traffic sites. If you’re getting page views every few seconds, cron events fire close to their scheduled time. But if your site gets sporadic traffic—common for new operators, niche blogs, or B2B content sites—you might not get a visitor at 9:00 AM. The post sits in the queue until someone (or something) hits the site.

    When scheduling breaks

    Three common failure modes:

    Low traffic delays publication. If your site averages ten visitors per hour and you schedule a post for 6:00 AM, it might not publish until 6:43 AM when the first human visitor triggers wp-cron.php. Search engines and RSS readers may already have crawled your site and missed it.

    Caching plugins disable wp-cron.php. Some full-page caching setups (especially aggressive CDN configs or static site generators bolted onto WordPress) block the background HTTP request to wp-cron.php. The page load completes, but the cron event never fires. Posts stay in “scheduled” status indefinitely.

    The cron queue gets clogged. If a plugin registers dozens of cron events—backup scripts, API syncs, email queue processors—and one of those tasks hangs, the entire queue stalls. WordPress processes cron events sequentially in a single request. A 30-second timeout on one event blocks everything behind it, including your scheduled post.

    How to fix it

    The cleanest solution: disable WordPress’s fake cron and use real server-level cron instead.

    Add this line to wp-config.php:

    define('DISABLE_WP_CRON', true);

    Then add a real cron job via your hosting control panel or SSH. Most hosts (including BigScoots, SiteGround, and Kinsta) let you add cron jobs through cPanel or a custom dashboard. Set it to run every 5–15 minutes:

    */15 * * * * wget -q -O - https://yoursite.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1

    Or use curl if wget isn’t available:

    */15 * * * * curl -s https://yoursite.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1

    Now cron events fire on schedule, regardless of traffic. Posts publish within 15 minutes of their target time (or within 5 minutes if you set the interval tighter). RSS readers and search crawlers see content when you intended.

    One non-obvious benefit: this setup also makes plugin-based automations more reliable. If you’re using WordPress to queue emails, sync data to external APIs, or run nightly cleanup tasks, real cron ensures those jobs complete even when your site is quiet.

    One thing to watch

    If you run real cron and have high traffic, you might end up triggering wp-cron.php twice in the same minute—once from the server cron job, once from a visitor’s page load. This usually isn’t a problem (WordPress locks cron execution to prevent duplicate runs), but if you’re obsessive about server load, keep DISABLE_WP_CRON enabled and let the server-level job handle everything.

    If you’d rather not touch server config, a few managed WordPress hosts (Kinsta, WP Engine) run real cron by default. Check your host’s documentation—some silently replace wp-cron.php without telling you.

    Want more infrastructure breakdowns like this? Subscribe to One Two Three Send—we explain the invisible plumbing that makes (or breaks) online businesses.

  • WordPress cache expiration headers: what actually gets cached

    WordPress cache expiration headers: what actually gets cached

    WordPress cache expiration headers: what actually gets cached
    Photo: Gaurav Dhwaj Khadka via Wikimedia Commons (CC BY-SA 4.0)

    Most WordPress operators assume their caching plugin handles everything. It doesn’t. Cache-Control and Expires headers—set at the server or application level—determine what gets stored by browsers, proxies, and CDNs, and for how long. WordPress sets some of these by default. Others depend on your host, your caching plugin, or manual configuration.

    If you’ve ever wondered why a CSS file updates instantly but a hero image sticks around for days, or why logged-in users see stale content, the answer is in the headers. Here’s what actually gets cached, what WordPress controls, and when you need to intervene.

    What WordPress sets by default

    Out of the box, WordPress sends Cache-Control: no-cache, must-revalidate, max-age=0 for most dynamic pages—posts, archives, and any page generated by PHP. This tells browsers and intermediaries not to cache the response without revalidation.

    Static assets—images, CSS, JavaScript uploaded to /wp-content/uploads or enqueued via wp_enqueue_script—typically don’t get cache headers from WordPress itself. Your web server (Apache, Nginx) or host sets them. Most hosts default to one year (max-age=31536000) for images and fonts, and shorter windows (one week to one month) for CSS and JS.

    If you’re on shared hosting without custom server config, you’re stuck with those defaults unless you use a plugin or CDN to override them.

    Where caching plugins take over

    Full-page caching plugins—WP Rocket, W3 Total Cache, LiteSpeed Cache—generate static HTML and serve it with their own headers. Most set Cache-Control: public, max-age=3600 (one hour) or longer for cached pages, and private, no-cache for logged-in users.

    The problem: if your plugin sets a one-hour cache but your CDN or browser already cached the page for 24 hours, the shorter directive won’t matter. Cache layers stack. The longest-lived cache wins until it expires or gets purged.

    Check what’s actually being sent by opening DevTools, loading a page, and inspecting the response headers under the Network tab. Look for Cache-Control, Expires, and Age. If Age is present, the response came from a cache. If it’s missing, it’s a fresh hit.

    When to override the defaults

    Override cache headers when:

    • You version assets manually. If you append ?v=2 or use a build hash in filenames, set a long max-age (one year). The filename or query string will bust the cache when you update.
    • You serve user-specific content. Set Cache-Control: private so CDNs and shared proxies don’t serve one user’s view to another. Cookies usually trigger this automatically, but not always.
    • You publish time-sensitive content. If your site updates every few minutes (live scores, stock tickers, event countdowns), set max-age=60 or lower and pair it with s-maxage for CDN-specific caching.
    • Your CDN and origin disagree. Some CDNs (Cloudflare, for example) respect origin headers by default but let you override them with page rules. If your origin says “cache for one hour” but your CDN caches for 24, you’ll serve stale content unless you configure the CDN to honor the shorter window or purge on publish.

    The non-obvious detail: stale-while-revalidate

    Modern browsers and CDNs support stale-while-revalidate, a directive that serves cached content even after it expires while fetching a fresh copy in the background. If you set Cache-Control: max-age=600, stale-while-revalidate=300, the cache serves the page for 10 minutes, then for another 5 minutes while revalidating. The user never waits, and your server gets fewer simultaneous requests.

    Most WordPress caching plugins don’t expose this setting. You’ll need to add it via your host’s control panel, a custom Nginx/Apache config, or a CDN rule. It’s worth it if you publish irregularly and want to keep pages fast without manual purging.

    How to check what’s actually cached

    Run a quick test:

    • Open an incognito window and load your homepage.
    • Open DevTools → Network → reload the page.
    • Click on the document request (usually the first row) and check the Response Headers.
    • Look for Cache-Control, Expires, Age, and X-Cache (CDN-specific).

    If you see max-age=0 on a static page, your caching plugin isn’t running or isn’t configured for that route. If you see Age: 86400 (24 hours in seconds), the page was cached a day ago and hasn’t been purged.

    Repeat the test logged in. If the headers are identical, you’re serving cached pages to authenticated users—a problem if your site shows user-specific content or admin bars.

    Want more infrastructure breakdowns like this? Subscribe to One Two Three Send for weekly deep-dives into the systems that run online businesses—no fluff, just the details that matter.

  • WordPress CDN purge delays: when edge caches serve stale content

    WordPress CDN purge delays: when edge caches serve stale content

    WordPress CDN purge delays: when edge caches serve stale content
    Photo by WebFactory Ltd on Unsplash

    You publish a correction to a post, hit update, and reload the page. It looks fine. Two hours later, a reader emails you about the mistake you just fixed—because they’re still seeing the old version.

    CDN edge caches don’t purge instantly. Even when your WordPress hosting provider or CDN plugin claims “automatic purge on publish,” the signal has to propagate across dozens or hundreds of edge nodes. Some refresh in seconds. Others take minutes. A few stragglers can hold stale content for five, ten, even fifteen minutes.

    If you run a content site, newsletter archive, or any business where correctness matters more than speed, you need to understand how purge delays work—and when to bypass the CDN entirely.

    How CDN purge propagation actually works

    When you update a WordPress post, your caching plugin or hosting control panel sends a purge request to the CDN. That request tells the CDN to invalidate the cached copy of a specific URL.

    But CDNs don’t store one copy of your page. They store dozens, distributed across edge nodes in different cities and regions. The purge request has to reach every node that cached that URL. Some CDNs use a hub-and-spoke model, where a central controller tells edge nodes to purge. Others rely on eventual consistency, where nodes check in periodically and sync purge lists.

    The result: purge latency varies by CDN provider, geographic distribution, and how the edge node last synced. Cloudflare’s global purge typically completes in under 30 seconds. StackPath and BunnyCDN clock in around 30–60 seconds. Older or budget CDNs can take two to five minutes. Regional edge nodes in low-traffic zones sometimes lag another five minutes beyond that.

    If your reader hits an edge node in Tokyo thirty seconds after you purge in New York, they might still see the cached version. The purge request is in flight, but hasn’t landed yet.

    When purge delays break your workflow

    For most blog posts, a one-minute delay doesn’t matter. Readers won’t notice. But three scenarios make purge lag painful:

    • Breaking news or time-sensitive corrections. If you publish live updates, product launches, or breaking analysis, stale caches mean early readers see outdated information. You can’t control which edge node they hit.
    • Email newsletter links that drive immediate traffic. You send a newsletter linking to a new post. Subscribers in different regions click within seconds of each other. Some see the post. Others hit a 404 or an older draft, because the edge node nearest them hasn’t purged yet.
    • Affiliate links, pricing, or legal copy. If you update an affiliate disclosure, a product price, or terms of service, you need every reader to see the current version immediately. A five-minute cache lag exposes you to inconsistency or compliance risk.

    Purge delays also compound when you use multiple caching layers. If your host runs Varnish or Nginx caching in front of the origin, and you layer a CDN on top, you have two purge queues. A plugin might purge the CDN instantly but leave the host cache intact for another sixty seconds. The CDN pulls the stale copy from the host, re-caches it, and now you’re waiting for both layers to expire.

    What to do when instant purge matters

    If you need guaranteed fresh content, you have three levers:

    Bypass the CDN for critical URLs. Most CDNs let you set cache rules per path. If you have a /live subdirectory or a specific post slug that needs zero cache lag, exclude it from edge caching entirely. The page will load slightly slower, but every reader sees the current version.

    Use query string cache busting. Append a unique query parameter to the URL when you update the post—something like ?v=1721203487. The CDN treats it as a new URL and skips the cache. This works for newsletter links, social shares, or any scenario where you control the inbound link. It doesn’t help organic search traffic, because Google ignores query strings in most cases.

    Monitor purge lag and escalate if necessary. If you’re on a managed WordPress host like BigScoots, WP Engine, or Kinsta, check whether they expose purge status in the dashboard. Some hosts show per-node purge completion. If you see consistent lag on specific regions, open a support ticket. Misconfigured edge nodes or stale DNS can cause outlier delays that support can fix.

    For most solo operators, the simplest fix is setting shorter cache TTLs on high-stakes pages. If your default CDN cache is set to 24 hours, dial it back to 5 or 10 minutes for posts you update frequently. You lose some performance benefit, but you cut the worst-case purge window from hours to minutes.

    The non-obvious detail: browser cache adds another layer

    Even if your CDN purge completes in ten seconds, readers might still see stale content for minutes or hours—because their browser cached the page. CDN purges don’t touch client-side caches.

    If your WordPress caching plugin or CDN sets Cache-Control: max-age=86400 (24 hours) in HTTP headers, the reader’s browser won’t even ask the CDN for a fresh copy until that timer expires. You can purge the CDN a hundred times; it won’t matter.

    Check your caching plugin’s browser cache setting. For posts you update often, set browser cache TTL to 5 or 10 minutes, not hours. That way, readers who reload the page within a few minutes will fetch the updated version from the CDN, even if their initial pageview hit a stale edge node.

    One more thing: if you’re testing purge behavior yourself, open an incognito window or clear your browser cache before each reload. Otherwise you’re testing your local cache, not the CDN.

    Got a caching or hosting question? Reply to this email—we cover reader questions every Sunday.

  • WordPress revision limits: when unlimited history breaks your database

    WordPress revision limits: when unlimited history breaks your database

    WordPress revision limits: when unlimited history breaks your database
    Photo: Matinbeigi via Wikimedia Commons (CC BY-SA 4.0)

    WordPress stores every draft change you make as a separate database row. Hit “Save Draft” twenty times while writing a post? That’s twenty revision rows in wp_posts. Multiply that across hundreds of articles, and you’re storing thousands of rows you’ll never open again.

    Most operators don’t notice until their database export takes five minutes instead of thirty seconds, or their backup plugin starts timing out. By then, revisions often account for 40–60% of total database size.

    The revision math

    WordPress defaults to unlimited revisions. A typical blog post might generate 15–25 revisions during drafting and editing. If you publish 100 posts per year, that’s 1,500–2,500 revision rows annually—on top of the 100 published post rows you actually need.

    After three years, a site with 300 published posts might carry 4,500–7,500 revision rows. Each revision duplicates the post content, metadata, and timestamps. A 2,000-word post stored as HTML averages 12–15 KB per revision. Twenty revisions = 240–300 KB of database space for a single article’s edit history.

    Database bloat compounds during backups. Tools like UpdraftPlus or BackupBuddy export the entire wp_posts table. A 300-post site with unlimited revisions can generate 80–120 MB backup files when the live content itself occupies 15–20 MB.

    Setting a sane limit

    Add this line to wp-config.php, above the “stop editing” comment:

    define('WP_POST_REVISIONS', 5);

    Five revisions cover most real-world recovery scenarios. You can roll back a paragraph you deleted an hour ago, compare yesterday’s draft to today’s, or retrieve content after an accidental bulk delete. You won’t need the version from three weeks ago.

    Setting the limit doesn’t delete existing revisions—it only caps future saves. To purge old revisions, install WP-Optimize (free) or run this SQL query directly in phpMyAdmin:

    DELETE FROM wp_posts WHERE post_type = 'revision';

    Test on staging first. The query is irreversible.

    When to keep more revisions

    Multi-author sites benefit from higher limits—10 to 15 revisions—especially when editors frequently revert contributor drafts or compare versions across handoffs. Content sites that publish long-form investigative pieces or data-heavy articles might want a week’s worth of recovery options.

    But solo operators publishing 2–4 posts per week rarely need more than five. The trade-off between database bloat and recovery flexibility tips heavily toward limiting revisions once you pass 200 published posts.

    Autosave vs. revisions

    WordPress autosaves every 60 seconds by default, separate from manual revisions. Each autosave overwrites the previous one—it doesn’t create a new row. Autosaves don’t contribute to bloat, but they do create a recovery point independent of your revision limit.

    If you want to extend the autosave interval to reduce database writes (useful on high-traffic shared hosting), add this to wp-config.php:

    define('AUTOSAVE_INTERVAL', 300);

    That sets autosave to five minutes. Solo operators can safely push this to 180–300 seconds without meaningful risk.

    Monitoring revision bloat

    Run this query in phpMyAdmin to count total revisions:

    SELECT COUNT(*) FROM wp_posts WHERE post_type = 'revision';

    Compare that number to your published post count. If revisions outnumber posts by more than 10:1, you’re carrying dead weight. Purge old revisions, set a limit, and check again in three months.

    Database optimization plugins like WP-Optimize show revision counts in their dashboards and let you schedule monthly cleanups. Set it to keep the five most recent revisions per post and auto-delete the rest. After the first purge, most solo sites drop 30–50% in database size.

    Want more WordPress infrastructure breakdowns like this? Reply and tell me which hosting mystery to unpack next—I read every response.

  • WordPress database character set conversion: when emoji breaks old posts

    WordPress database character set conversion: when emoji breaks old posts

    WordPress database character set conversion: when emoji breaks old posts
    Photo by Markus Winkler on Unsplash

    Most WordPress sites installed before 2015 run on a latin1 character set. That was fine until emoji, non-Latin scripts, and special characters became standard in content. The fix is converting to utf8mb4, the MySQL character set that supports the full Unicode range. But the migration isn’t one-click safe, and operators who skip the collation audit often find mangled text in old posts weeks later.

    Why utf8mb4 matters now

    WordPress has shipped with utf8mb4 as the default character set since version 4.2 in 2015. If your database predates that, or if your host used an older MySQL template during setup, you’re likely still on latin1 or the older three-byte utf8 encoding.

    The three-byte utf8 can’t store emoji, many Asian characters, or mathematical symbols. If a reader pastes an emoji into a comment form or you copy-paste content from a doc that includes a smart quote outside the latin1 range, MySQL either rejects the insert or converts the character to a question mark.

    utf8mb4 uses four bytes per character and covers the entire Unicode standard. That means emoji, CJK ideographs, and symbols render correctly and survive round-trip edits in the WordPress editor.

    The collation mismatch that breaks content

    Character set defines which characters MySQL can store. Collation defines how MySQL compares and sorts them. When you convert a database from latin1 to utf8mb4, you also change the collation—typically from latin1_swedish_ci to utf8mb4_unicode_ci or utf8mb4_general_ci.

    If your existing content contains extended ASCII characters—curly quotes, em dashes, accented letters—and those were stored as latin1 bytes, a straight conversion can misinterpret them. MySQL reads the old byte sequence as if it were already utf8mb4, producing garbled output: “don’t” instead of “don’t.”

    This happens because the byte 0x92 in latin1 (right single quotation mark) has a different meaning in utf8mb4. The conversion tool changes the schema but doesn’t transcode the data unless explicitly told to.

    How to convert without breaking old posts

    The WordPress database charset converter in wp-admin → Tools → Database (added in WP 4.2) handles most cases, but it only appears if WordPress detects a charset mismatch. If the tool doesn’t show, your database and wp-config.php already match, or your host disabled the feature.

    For manual conversion, use the WP-CLI command:
    wp db convert-charset utf8mb4

    This command checks each table, converts the schema, and attempts to recode existing data. But it can’t fix data that was already double-encoded or incorrectly inserted under the old charset.

    Before you run any conversion:

    • Export a full database backup via phpMyAdmin or your host’s backup tool.
    • Check your MySQL version. utf8mb4 requires MySQL 5.5.3 or later; most hosts are on 8.0+ now, but confirm.
    • Run a test query to identify problem rows:
      SELECT post_title, post_content FROM wp_posts WHERE post_content LIKE '%â€%' LIMIT 20;
      If you see garbled characters, your data is already mis-encoded and needs a two-step fix.

    If you find corrupted rows, you’ll need to convert from the actual source encoding. Many databases labeled latin1 were silently storing utf8 bytes. In that case, convert from utf8 to utf8mb4, not latin1 to utf8mb4. Tools like mysqldump with the --default-character-set flag let you specify the real source encoding.

    What breaks after conversion

    Index length limits are the most common post-conversion issue. utf8mb4 uses four bytes per character, so a VARCHAR(255) index that worked under latin1 (255 bytes) now requires 1,020 bytes (255 × 4). MySQL’s default InnoDB index prefix limit is 767 bytes on older configurations.

    If you hit this during conversion, you’ll see:
    ERROR 1071 (42000): Specified key was too long; max key length is 767 bytes

    The fix: enable innodb_large_prefix and set innodb_file_format=Barracuda in your MySQL config, or shorten the indexed column. Most modern managed WordPress hosts (including BigScoots) run MySQL 5.7+ with large prefixes enabled by default, but check if you manage your own VPS.

    Plugins that write custom tables may also need updates. If a plugin creates a table after your conversion, it might default to the old charset unless the plugin explicitly sets utf8mb4 in its schema. Check any membership, LMS, or e-commerce plugins with custom tables.

    When to skip the conversion

    If your site is English-only, has no user-generated content, and you never use emoji or special characters, the conversion won’t break anything—but it also won’t deliver visible value. The character set becomes important when:

    • You accept comments, forum posts, or form submissions from readers
    • You publish multilingual content or quote non-English sources
    • You paste from Google Docs, Notion, or other tools that insert smart quotes and em dashes
    • You use emoji in headlines, CTAs, or social-share snippets

    For solo operators running a single-language blog with no UGC, the status quo works. But if you plan to scale, open comments, or accept guest posts, convert now before you have 500 posts to audit.

    One last thing: After conversion, update your wp-config.php to match. Set:
    define('DB_CHARSET', 'utf8mb4');
    define('DB_COLLATE', 'utf8mb4_unicode_ci');

    Without that, WordPress will keep writing new content in the old charset, and you’ll be back where you started.

    Hit a wall with WordPress hosting performance or database issues? Subscribe to One Two Three Send for operator-focused breakdowns every week—no fluff, just the technical details that matter.

  • WordPress image optimization plugins: lazy load vs. CDN rewrite logic

    WordPress image optimization plugins: lazy load vs. CDN rewrite logic

    WordPress image optimization plugins: lazy load vs. CDN rewrite logic
    Photo by sarah b on Unsplash

    Most WordPress sites run at least one image optimization plugin. Many run two or three at once: a lazy load plugin, a CDN rewrite plugin, and maybe an image compression tool. The problem is that these plugins don’t coordinate—they stack transformations on top of each other, and sometimes those transformations conflict.

    The result isn’t always visible. Your images might load fine on desktop, then break on mobile. Or they load, but the CDN never serves them. Or lazy load fires twice, delaying the render by an extra second.

    Here’s how lazy load and CDN rewrite logic actually work, where they collide, and how to configure them so they don’t cancel each other out.

    Lazy load rewrites your image tags before the page renders

    Lazy load plugins work by intercepting the HTML output buffer just before WordPress sends the page to the browser. They scan for <img> tags, rewrite the src attribute to a placeholder (often a transparent 1×1 GIF or inline SVG), and move the real image URL into a data-src attribute. A JavaScript library watches the viewport and swaps data-src back into src when the image is about to scroll into view.

    That rewrite happens after WordPress generates the HTML, but before it gets sent to the browser. Most lazy load plugins hook into the the_content filter or the final output buffer, which means they’re one of the last things to touch the page.

    The timing matters, because CDN rewrite plugins also hook into the output buffer—and they’re looking for image URLs to rewrite.

    CDN rewrite plugins replace your domain with the CDN endpoint

    A CDN rewrite plugin scans the same HTML output buffer and looks for any URL pointing to your domain. When it finds an image URL like https://yourdomain.com/wp-content/uploads/image.jpg, it replaces the domain with your CDN endpoint: https://cdn.yourdomain.com/wp-content/uploads/image.jpg or https://abc123.cloudfront.net/image.jpg.

    That rewrite is simple: it’s a string replacement. The plugin doesn’t parse the HTML into a DOM tree—it just searches for your domain and swaps it out.

    But if the lazy load plugin has already moved the real image URL into data-src, the CDN plugin might not find it. Some CDN plugins only rewrite src attributes. Others rewrite both src and data-src, but the order matters.

    When the wrong plugin runs first, images don’t serve from the CDN

    If the lazy load plugin runs before the CDN rewrite plugin, the CDN plugin sees src="data:image/svg+xml…" and data-src="https://yourdomain.com/image.jpg". If it’s only configured to rewrite src, it skips the image. The image loads from your origin server, not the CDN.

    You won’t see an error. The image still loads. But you’re paying for CDN bandwidth and getting none of the latency benefit.

    If the CDN plugin runs before lazy load, it rewrites the src to the CDN URL, then lazy load moves that CDN URL into data-src and replaces src with a placeholder. That works—but only if the lazy load plugin doesn’t strip attributes it doesn’t recognize.

    How to check which plugin runs first

    View the page source in your browser. Don’t use the inspector—view the raw HTML that the server sends. Look for an <img> tag and check three things:

    • Does the src attribute point to a placeholder or a real image?
    • Does the data-src (or data-lazy-src) attribute exist, and does it point to your CDN or your origin domain?
    • Are there any other data- attributes you don’t recognize? Some plugins add their own.

    If data-src points to your origin domain, lazy load is running before CDN rewrite, and the CDN isn’t serving those images.

    If data-src points to your CDN, the order is correct—but you should also check the Network tab in DevTools to confirm the image actually loads from the CDN and doesn’t 404.

    The fix: force CDN rewrite to run last, or use native lazy loading instead

    Most WordPress lazy load plugins let you set a priority for the output buffer filter. Lower numbers run earlier. If your CDN plugin priority is set to 10 and your lazy load plugin is also 10, WordPress runs them in the order they were loaded—which is unpredictable.

    Set your CDN rewrite plugin priority to something high, like 99 or 999, so it runs after lazy load. That way it catches both src and data-src attributes.

    Alternatively, skip the lazy load plugin entirely and use native browser lazy loading. Add loading="lazy" to your <img> tags. WordPress has done this by default since version 5.5 for any image in post content. Native lazy loading doesn’t rewrite attributes, so there’s no conflict with CDN plugins.

    The tradeoff: native lazy loading doesn’t let you control the threshold (how far from the viewport the image loads), and it doesn’t work in older browsers. But for most solo operators, that’s fine. The performance gain from avoiding two competing JavaScript libraries outweighs the loss of fine-grained control.

    Want more WordPress performance breakdowns like this? Subscribe to One Two Three Send—one article like this, every day, in your inbox.

  • WordPress plugin auto-updates break staging—here’s the override

    WordPress plugin auto-updates break staging—here’s the override

    WordPress plugin auto-updates break staging—here's the override
    Photo by Fikret tozak on Unsplash

    WordPress auto-updates shipped as opt-in for plugins in 5.5, then flipped to opt-out for most managed hosts by 2025. The feature works—until you run a staging environment.

    The problem: auto-update preferences are stored in the database, not in code. When you enable auto-updates for a plugin on production, then clone your site to staging for testing, staging inherits that same setting. If staging pulls an update first and you later sync the database back to production, you’ve just pushed untested code live without realizing it.

    Most operators discover this after a layout breaks or a payment form stops submitting. The fix isn’t intuitive, because the WordPress dashboard shows auto-updates as a per-plugin toggle with no environment awareness.

    Where auto-update state lives

    Plugin auto-update preferences are stored in the wp_options table under the key auto_update_plugins. It’s a serialized array of plugin basenames. When you enable auto-updates for a plugin, WordPress adds its basename (e.g., akismet/akismet.php) to that array. When you clone a database, you clone that array.

    Managed hosts like WP Engine, Kinsta, and BigScoots run their own update schedules—usually twice daily. If your staging site is on the same host infrastructure and shares the same cron timing, it can pull updates before production does. The update itself isn’t the issue; it’s the accidental database sync afterward that surfaces the problem.

    This is distinct from WordPress core auto-updates, which are controlled by the AUTOMATIC_UPDATER_DISABLED and WP_AUTO_UPDATE_CORE constants. Those live in wp-config.php and can be set per environment. Plugin auto-updates don’t have an equivalent constant—yet.

    The config-level override

    You can disable plugin auto-updates entirely on staging using a filter in your theme’s functions.php or a custom plugin. Add this to your staging environment only:

    add_filter( 'auto_update_plugin', '__return_false' );

    This filter runs before WordPress checks the auto_update_plugins option, so it overrides any database settings. Your staging site will never auto-update plugins, even if the dashboard toggle is on. Production can still auto-update as configured.

    If you want more control—say, allow auto-updates for specific plugins on staging but block others—you can inspect the $update object passed to the filter:

    add_filter( 'auto_update_plugin', function( $update, $item ) {
      $allowed = [ 'akismet/akismet.php', 'hello-dolly/hello.php' ];
      return in_array( $item->plugin, $allowed );
    }, 10, 2 );

    This approach whitelists only the plugins you name. Everything else stays manual.

    The deployment risk most operators miss

    The inverse scenario is equally common: you disable auto-updates on production because you want full control, then push your database to staging for testing. Staging now also has auto-updates disabled—but you wanted staging to pull updates automatically so you could test them before manually applying to production.

    The cleanest fix is to treat auto-update state as environment-specific config, not content. That means:

    • Set the filter in code, per environment
    • Ignore the dashboard toggle, or document that it’s overridden
    • Never sync the auto_update_plugins option between environments

    If you’re using a database sync tool like WP Migrate or All-in-One WP Migration, add auto_update_plugins to your exclusion list. Most tools let you skip specific option keys during import.

    When auto-updates make sense

    For solo operators running a single production site with no staging, auto-updates work well for low-risk plugins: Akismet, Yoast SEO, contact forms. The risk is low because you’re already trusting the plugin author’s release process, and critical security patches ship faster than you’d apply them manually.

    For sites with custom plugins, membership logic, or payment processing, the calculus flips. An auto-update that changes a filter priority or deprecates a function can break checkout flows without warning. Staging exists to catch that—but only if staging doesn’t push updates back to production through an accidental database sync.

    The non-obvious tip: if you run multiple staging environments (e.g., one for content review, one for plugin testing), disable auto-updates on the content-review staging and enable them on the plugin-test staging. That way your content team never sees surprise layout changes, and your dev environment always has the latest code to test against.

    Want more WordPress operations breakdowns? Reply with the hosting or plugin behavior you want explained next—we cover one every Monday.

  • WordPress full-page caching: when logged-in users break it

    WordPress full-page caching: when logged-in users break it

    WordPress full-page caching: when logged-in users break it
    Photo by Souvik Banerjee on Unsplash

    Most WordPress caching plugins promise faster page loads by storing static HTML and serving it to every visitor. That works beautifully—until someone logs in. Then the plugin faces a choice: serve the cached page and risk showing the wrong content, or bypass the cache entirely and slow the site back down.

    The default behavior varies wildly across plugins, and most solo operators don’t notice the problem until a reader reports seeing someone else’s dashboard, a broken checkout flow, or stale course-access gates.

    What full-page caching actually does

    Full-page caching generates a static HTML snapshot of each URL the first time someone visits it. Subsequent visitors get that snapshot instead of triggering PHP, database queries, and theme rendering. The speedup is dramatic—page load times drop from 800ms to under 100ms.

    But that snapshot is user-agnostic. It doesn’t know who’s viewing it. If the first visitor was logged out, the cached page shows a logged-out state. If you’re running a membership site, a course platform, or even just WordPress comments with reply threading, that’s a problem.

    Most caching plugins handle this by disabling the cache entirely for logged-in users. WP Rocket, W3 Total Cache, and WP Super Cache all do this by default. The moment you log in, you’re back to slow, uncached page loads.

    That’s fine if you’re the only one logged in. It’s not fine if you’re running a membership site with 500 active students, or a community forum where everyone has an account.

    The exceptions list: what to exclude

    Every caching plugin includes an exclude list—URLs or cookies that bypass the cache. The problem is that the defaults are conservative, and the documentation rarely explains why each rule exists.

    Here’s what typically needs exclusion:

    • Cart and checkout pages. WooCommerce, Easy Digital Downloads, and MemberPress all set cookies that modify page content. Cache those, and users see each other’s cart totals.
    • Account dashboards. Any page showing user-specific data—order history, profile settings, course progress—needs to bypass the cache.
    • Dynamic forms. Contact forms, quiz plugins, and conditional-logic forms often rely on nonces or session tokens. Cached nonces expire, and form submissions fail.
    • Personalized content blocks. If you’re using a plugin that shows different CTAs, testimonials, or upgrade prompts based on user role, those blocks need to load fresh.

    The tricky part: most plugins let you exclude by URL slug (/account/, /checkout/) but not by user role. That means you can’t serve cached pages to free members while bypassing the cache for paid members viewing the same URL.

    The two-tier workaround: fragment caching

    If you need fast page loads for logged-in users and personalized content, you need fragment caching—where parts of the page are cached, but user-specific elements load dynamically.

    This isn’t built into most full-page caching plugins. You need a CDN or a caching layer that supports Edge Side Includes (ESI) or a JavaScript-based approach where the page skeleton loads from cache and an AJAX call fetches user-specific content.

    Cloudflare Workers, Fastly, and some managed WordPress hosts (Kinsta, WP Engine) support ESI. For solo operators on shared hosting, the easier path is often to skip full-page caching for logged-in users entirely and optimize the uncached experience instead—faster database queries, lazy-loaded images, deferred JavaScript.

    Configuration that actually works

    Here’s a baseline exclude list for a typical membership or course site running on WordPress:

    • Any URL containing /my-account/, /dashboard/, or /profile/
    • Cart, checkout, and thank-you pages (plugin-specific slugs)
    • Any page using the wordpress_logged_in_* cookie
    • AJAX endpoints (/wp-admin/admin-ajax.php)
    • REST API routes serving dynamic data (/wp-json/ if you’re using it for frontend updates)

    Test this by logging in, visiting a cached page, and checking the HTTP headers. Look for X-Cache: HIT or X-Cache: BYPASS. If you see HIT on a page that should be personalized, add it to the exclude list.

    Most caching plugins also let you exclude by query string. If you’re running A/B tests or tracking UTM parameters, make sure those aren’t triggering separate cache entries—you’ll bloat storage and fragment your cache hit rate.

    One more thing: if you’re using a managed host with server-level caching, check whether their rules override your plugin settings. Kinsta and Flywheel both have their own caching layers that ignore plugin configurations. You’ll need to contact support or use their dashboard to adjust exclusions.

    Want more deep dives on the infrastructure behind online businesses? Subscribe to One Two Three Send—one focused article like this, delivered weekly.

  • WordPress media library search doesn’t index alt text—here’s why

    WordPress media library search doesn’t index alt text—here’s why

    WordPress media library search doesn't index alt text—here's why
    Photo by Justin Morgan on Unsplash

    If you’ve ever typed a keyword into the WordPress media library search bar and watched it return nothing—despite knowing you meticulously added alt text to that exact image three months ago—you’re not imagining things. WordPress media library search doesn’t index alt text by default. It only searches file names, titles, captions, and descriptions.

    For solo operators managing a growing library of stock photos, screenshots, and custom graphics, this is more than an annoyance. It’s a workflow tax that compounds every time you need to reuse an image.

    What the media library search actually indexes

    Out of the box, WordPress media search queries four fields: the attachment post title (usually derived from the file name), the caption, the description, and the file name itself. Alt text lives in a separate post meta field (_wp_attachment_image_alt) and is deliberately excluded from the default search query.

    This decision dates back to WordPress core architecture. Alt text was added later than the other fields, and backwards compatibility concerns kept it out of the search scope. The result: you can have 400 images with perfect alt text and zero ability to surface them by searching for the words you wrote.

    If you’re running a content site where images get reused across posts—product screenshots, annotated charts, author headshots—this creates two problems. First, you waste time scrolling or re-uploading duplicates. Second, you’re less likely to maintain good alt text hygiene if the system doesn’t reward you for it.

    Why this matters for accessibility and SEO

    Alt text isn’t just for screen readers. It’s also the fallback when images fail to load, and it contributes to image SEO. If your workflow separates “alt text for accessibility” from “searchable metadata,” you’re maintaining two systems where one should suffice.

    Many operators add keywords to the image title or caption purely to make search work, then duplicate the same text in alt text for compliance. That’s redundant labor. Worse, it encourages bad alt text—cramming keywords instead of describing the image meaningfully.

    A better system would let you search by alt text and trust that the field serves both accessibility and discoverability. WordPress doesn’t do this natively, but you can fix it.

    How to make alt text searchable

    The cleanest solution is a small function added to your theme’s functions.php or a custom plugin. This snippet hooks into the media library query and includes alt text in the search scope:

    function extend_media_search_alt_text( $where, $query ) {
      global $wpdb;
      if ( $query->is_search() && isset( $query->query_vars['post_type'] ) && $query->query_vars['post_type'] === 'attachment' ) {
        $search_term = $wpdb->esc_like( $query->query_vars['s'] );
        $where .= $wpdb->prepare( " OR ( $wpdb->postmeta.meta_key = '_wp_attachment_image_alt' AND $wpdb->postmeta.meta_value LIKE %s )", '%' . $search_term . '%' );
      }
      return $where;
    }
    add_filter( 'posts_where', 'extend_media_search_alt_text', 10, 2 );

    This tells WordPress to join the postmeta table and include alt text when searching attachments. Once active, typing “pricing chart” into the media library will return any image with that phrase in the alt text—even if the file name is IMG_3487.png.

    If you’re not comfortable editing PHP, a handful of plugins handle this. Search & Filter and Enhanced Media Library both extend media search, though they add other features you may not need. The function above is lighter and does one thing well.

    When to use file names instead

    There’s one case where skipping alt text search makes sense: if you’re using a digital asset management system or a headless CMS that treats WordPress as a media CDN. In those workflows, images often arrive with machine-generated alt text or none at all, and file naming conventions carry the metadata load.

    But for most solo operators and small teams publishing directly in WordPress, alt text is where the human-readable description lives. Making it searchable closes the loop.

    Once you’ve added the function, spend ten minutes backfilling alt text on your most-reused images—screenshots, logos, charts. The next time you need that “customer dashboard screenshot with annotations,” you’ll type six words instead of scrolling through 200 uploads.

    Want more WordPress infrastructure fixes like this? Reply and tell us which core behavior wastes your time—we’ll cover it in a future issue.

  • WordPress comment spam plugins create database bloat you can’t see

    WordPress comment spam plugins create database bloat you can’t see

    WordPress comment spam plugins create database bloat you can't see
    Photo: Matinbeigi via Wikimedia Commons (CC BY-SA 4.0)

    Most WordPress comment spam plugins work the same way: they intercept bot submissions, run a quick validation check, then silently block the junk. Clean dashboard, no moderation queue overflow, problem solved.

    Except the logs don’t disappear. They accumulate in your database, often in custom tables the plugin created during activation. A site that publishes two posts a week might generate 40 rows of real comments per month—and 4,000 rows of blocked spam attempts that still get written to disk.

    The performance cost sneaks up slowly. MySQL queries take an extra 100ms, then 200ms. Backups grow from 50MB to 800MB. Your host starts warning you about inode limits. And because the spam logs live in separate tables, you won’t notice them in your Posts or Comments admin screens.

    What gets logged and where

    Popular plugins like Akismet log every checked comment, whether it’s marked as spam, passed, or auto-deleted. That metadata includes IP address, user agent string, submitted content, referrer, and timestamp. Each log entry runs 1–3KB depending on comment length.

    If your site receives 200 spam attempts per day—a conservative estimate for any public WordPress install older than six months—that’s 6,000 rows per month, or roughly 12MB of log data before indexing overhead.

    Some plugins store logs in wp_commentmeta, which inflates core WordPress tables. Others create dedicated tables like wp_akismet_log or wp_antispam_data. A few write to flat files in /wp-content/uploads, which can break backup scripts that assume uploads only contain media.

    The worst offenders don’t surface log size in their settings panels. You have to SSH in and run SELECT COUNT(*) queries or inspect table sizes via phpMyAdmin to know how much space the plugin has claimed.

    When bloat starts breaking things

    Database bloat doesn’t crash your site. It degrades it in ways that look like other problems.

    Slow admin page loads get blamed on a bad theme. Backup timeouts get blamed on host resource limits. Jump-to-comment anchor links that take two seconds to resolve get blamed on browser caching.

    The real bottleneck: MySQL has to scan larger indexes every time it touches the comments table, even if your query filters by post ID. If the spam log shares a table with legitimate comment metadata, every comment-related query pays the performance tax.

    One operator I spoke with last month had 1.4 million rows in their Akismet log spanning four years. Their live site showed 800 published comments. The log table alone consumed 2.8GB—more than the rest of their WordPress database combined. Clearing it dropped full-page cache generation time from 11 seconds to under three.

    Manual cleanup and plugin-level controls

    Most comment spam plugins include a log retention setting buried two or three tabs deep. Akismet’s lives under Settings → Akismet Anti-Spam → Privacy. The default is often “Keep logs indefinitely” or a vague “Delete old entries automatically” that triggers annually.

    Set retention to 30 or 60 days unless you’re actively investigating spam patterns or running forensic analysis. Logs older than eight weeks have near-zero diagnostic value—you’ve either already fixed the issue or decided to live with it.

    For immediate cleanup, you can truncate plugin log tables directly via SQL. Example for Akismet:

    TRUNCATE TABLE wp_akismet_log;

    Back up your database first. Some plugins store legitimate config data in the same table as logs, though most separate the two. If you’re unsure, export the table schema and first 100 rows before running destructive queries.

    After truncation, run OPTIMIZE TABLE wp_akismet_log; to reclaim disk space. MySQL marks deleted rows as free space but doesn’t shrink the table file until you explicitly optimise.

    Better: switch to lightweight validation

    If you’re not monetising comments and don’t rely on them for community interaction, turn off comments site-wide and uninstall the spam plugin entirely. You’ll shed the performance overhead and eliminate the log problem at the source.

    If you need comments on select posts, use a JavaScript-based honeypot or time-delay check instead of a server-side plugin that logs every request. Tools like Cloudflare Turnstile or hCaptcha validate humans without writing to your database.

    For high-traffic sites where moderation and spam filtering are non-negotiable, run your anti-spam checks in a separate microservice or worker process that doesn’t touch your WordPress database. Log to a dedicated logging service with automatic retention policies—Logtail, Papertrail, or even a self-hosted Loki instance—so old data expires without manual intervention.

    What you should do this week: SSH into your WordPress install or open phpMyAdmin. Check table sizes for any plugin with “spam,” “akismet,” “antispam,” or “comment” in the name. If any table exceeds 100MB and you’re not actively debugging spam issues, truncate it and set retention to 30 days. Your backups will thank you.

    Hit reply if you’ve found other plugins that hoard logs silently—we’ll cover the worst offenders in a future piece.