# Aditya Sharma > Product and growth lead who ships code, from Kolkata. Agentic AI in production, growth and positioning for technical products, LLM-facing SEO, Python crawlers, and server operations across fifty-plus production sites. Aditya Sharma works on product and growth at POSIMYTH Innovations, where the plugins he works on run on more than half a million sites: The Plus Addons for Elementor, Sticky Header Effects, Protuno, WDesignKit, Nexter and UiChemy. He owns none of those. He builds and runs Etica Studio with Raj: HostMy.blog, Hatch (headless WordPress), RankReady (LLM-facing SEO) and RunCloud server operations. His own products are Karma Jyotisha, Grabio and SproutOS. Seven tools he wrote to do his own job are open source: LibreCrawl (an AI-native technical SEO crawler, MCP), YouTube Marketing Skills, WordPress Malware Removal, Pickle, Orbit, Shush and Perch. He writes Agents in Production, one researched piece a week about AI agents doing real work rather than demos. Subjects covered here: agentic AI and MCP servers, marketing and positioning for technical products, technical and LLM-facing SEO, WordPress at scale, Python crawlers and content pipelines, server security and performance, and Vedic Jyotisha. Every number carries a named source and the date it was read. - Brand: Aditya Sharma, Aditya R Sharma, adityaarsharma, adityaarsharma.com, Marketer who codes, Agents in Production, Etica Studio, HostMy.blog, Hatch, RankReady, SproutOS, Grabio, Karma Jyotisha, LibreCrawl, Pickle, Orbit, Shush, Perch, POSIMYTH Innovations, The Plus Addons for Elementor, Sticky Header Effects, Protuno, WDesignKit, Nexter, UiChemy, agentic AI, AI agents, MCP servers, technical SEO, LLM SEO, Core Web Vitals, WordPress at scale, server operations, RunCloud, Kolkata --- # Home Source: https://adityaarsharma.com/ Aditya Sharma. Marketer who codes, from Kolkata. I am a product and growth lead who ships code. I run growth for a product company whose plugins run on more than half a million sites, build AI agents and MCP servers that do real work on real client data, keep the servers underneath them online, and write the research that backs all of it. People bring me the problems where the marketing and the engineering turn out to be the same problem. What I do: agentic AI in production. Growth and positioning for technical products. LLM-facing SEO, schema, llms.txt and Core Web Vitals. Python crawlers and content pipelines. Server operations across fifty-plus production sites on Hetzner, AWS and DigitalOcean. Content operations at volume. Currently: Product & Growth at POSIMYTH Innovations. Building Hatch and HostMy.blog at Etica Studio, the studio I run with Raj. Building a versioned memory layer for AI agents. Writing Agents in Production, a researched piece a week, at adityaarsharma.substack.com. What I have built, grouped by whose it actually is. Mine, owned outright: Karma Jyotisha, Grabio, SproutOS. Etica Studio, built and run with Raj: HostMy.blog, Hatch, RankReady, and RunCloud operations across fifty-plus servers. POSIMYTH, where I work on product and growth and own none of it: The Plus Addons for Elementor, Sticky Header Effects, Protuno, WDesignKit, Nexter, UiChemy. Open source, 116 GitHub stars across seven tools I wrote to do my own job and then gave away: LibreCrawl, YouTube Marketing Skills, WordPress Malware Removal, Pickle, Orbit, Shush, Perch. The route, and why it matters: video editing in 2018, then a 300K-subscriber YouTube channel, then WordPress, then servers, then the product and growth role, then products of my own. Nothing was replaced. Four of those are still running at once, which is the whole argument for hiring one person who holds six disciplines rather than six people who hold one. Where the method comes from: the Vedic side of my life is not an interest kept in a separate folder. Advaita, non-duality, means there is no second thing, so the server, the spreadsheet and the shrine are one field and there is nowhere in it to be careless. The tantric reading asks for full involvement in the world rather than withdrawal from it. Pramana, the tradition's strictness about how a claim is allowed to be made at all, is where the habit of naming a source and a date came from, long before it was an SEO idea. Karma: you own the effort, not the result, so I check the thing before I claim it and publish the check even when it goes against me. Every number on this site carries a named source and the date it was read. Where something is correlation I say correlation. When a finding makes my own work look worse it goes out anyway. Contact: adityaarsharma.com/connect. I reply within 24 hours. --- # Building Source: https://adityaarsharma.com/building/ Everything Aditya Sharma has put his name on, grouped by whose it actually is. Numbers read from the WordPress.org plugin API and the GitHub API on 4 September 2026; every URL returned HTTP 200 on 5 September 2026. Mine, owned outright: Karma Jyotisha, Vedic astrology computed properly, on its own domain. Grabio, a free iPhone shortcut that saves any video and converts any file entirely on device, with nothing uploaded anywhere. SproutOS, an operations layer for content teams, early access. Etica Studio, the studio he builds and runs with Raj: HostMy.blog, managed WordPress hosting for people who write, building since 2023. Hatch, headless WordPress without the six-week rebuild, a plugin plus an Astro starter plus a deploy broker that gets you a live URL in about ninety seconds, open source and self-hostable. RankReady, SEO for a web that reads with machines, covering schema, llms.txt, AEO and GEO, rated 100 out of 100. RunCloud operations across fifty-plus production servers on Hetzner, AWS and DigitalOcean, with the MCP server and Telegram agent that manage them. POSIMYTH, where he works on product and growth and owns none of it: The Plus Addons for Elementor, 100,000 active installs and 4.6 rating. Sticky Header Effects, 300,000 active installs. Protuno, agents for WordPress that take the backup, run the updates, check the site still loads and roll it back when it does not. WDesignKit, 30,000 installs, 4.8 rating. Nexter, 20,000 installs across two plugins. UiChemy, Figma to WordPress in one step, 9,000 installs and a 5.0 rating. WordPress.org reports active installs in rounded-down buckets, so each of those figures is a floor and the real total across the six is above half a million sites. Open source, 116 GitHub stars in total, every one of them a tool he wrote to do his own job and then gave away: LibreCrawl, an AI-native technical SEO crawler that audits a site the way an engineer would, 39 stars. YouTube Marketing Skills, twenty-one agent commands plus the research loop behind them, agent-agnostic, 38 stars. WordPress Malware Removal, audit, clean and harden end to end driven by an agent instead of a scanner that says clean, 31 stars. Pickle, a local MCP server that audits ClickUp, Slack and project sprawl, free and MIT permanently, 3 stars. Orbit, a ten-agent WordPress plugin QA team for Claude Code, 2 stars. Shush, strips PII and secrets out of a file before you paste it into an AI tool, 2 stars. Perch, a Telegram bot and Claude Code plugin for RunCloud server management, 1 star. The stack this page runs on, checked 5 September 2026: Hetzner with 4 vCPU and 7 GB on Ubuntu 24.04 LTS, provisioned through RunCloud, Cloudflare for DNS and edge. WordPress 7.1 on PHP 8.1 behind nginx, with no page builder anywhere on the site and a custom theme of one stylesheet and one script. GSAP and ScrollTrigger self hosted at 46 KB gzipped, Bricolage Grotesque as a self-hosted variable font under the OFL, and Canvas 2D for the plot on the home page. Built with Claude Code and MCP servers he wrote, with every layout claim measured in Playwright. --- # Connect Source: https://adityaarsharma.com/connect/ Connect with Aditya Sharma. Tell me what you are trying to do. I read every message and reply within 24 hours. If it is something I cannot help with I will say so in the first reply rather than book a call about it. What people usually bring me: 1. WordPress that has outgrown its setup. Performance, security, or fifty sites that need one way of working. 2. An AI agent that demos well and dies on contact. It works on a clean install and falls over on a client site with ten years of plugins. 3. SEO that has to survive a Google update, written by someone who reads the crawl data as well as the brief. 4. Content operations at volume, where the writing and the publishing pipeline are the same problem. 5. Servers that keep falling over, on AWS, DigitalOcean or Hetzner. What happens after you send it: it lands in my inbox, with no autoresponder and no drip sequence. I reply within 24 hours with either a real answer or an honest no. If it is a fit, we scope it in writing before anyone books a call. Elsewhere: x.com/adityaarsharma , linkedin.com/in/adityaarsharma , github.com/adityaarsharma , adityaarsharma.substack.com --- # Blogs Source: https://adityaarsharma.com/blogs/ Writing by Aditya Sharma. I check the things our industry takes on trust and write down what I actually found. Every number carries a named source and a date. Where something is correlation I say correlation, and when the finding makes my own work look worse it goes out anyway. Subjects: WordPress at scale, AI agents in production, technical SEO, Core Web Vitals, cyber security, automation, and the operations behind publishing at volume. The full index is at https://adityaarsharma.com/blogs/ . The newsletter, Agents in Production, sends one researched piece a week. --- # Terms of Service Source: https://adityaarsharma.com/terms-of-service/ This Terms of Service ("Agreement") is a legally binding contract between AdityaSharma ("adityaarsharma.com") and the user ("User" or "you"). By accessing and using AdityaSharma, you agree to be bound by the terms and conditions outlined in this Agreement. - Services 1.1 The Website offers web hosting services and other related services ("Services") to its users. 1.2 The User acknowledges that the Services provided by the Website are subject to availability, and the Website reserves the right to modify, suspend, or terminate any Service without prior notice. - User Responsibilities 2.1 The User agrees to provide accurate and up-to-date information when registering for an account and using the Services. 2.2 The User is responsible for maintaining the confidentiality of their account credentials and agrees not to share or disclose them to any third party. 2.3 The User shall use the Services in compliance with all applicable laws and regulations, including but not limited to Indian laws. - Payment Terms 3.1 The User agrees to pay the fees for the Services as specified on the Website. Payment methods and terms will be provided on the Website and may be subject to change. 3.2 All fees and charges are non-refundable, unless otherwise stated. - Intellectual Property 4.1 All content, trademarks, logos, and intellectual property rights displayed on the Website are the property of AdityaSharma or its licensors. The User agrees not to use, reproduce, modify, or distribute any intellectual property without prior written consent from AdityaSharma. - Limitation of Liability 5.1 The User acknowledges and agrees that the Website shall not be liable for any direct, indirect, incidental, consequential, or exemplary damages, including but not limited to loss of profits, data, or business opportunities. - Indemnification 6.1 The User agrees to indemnify and hold harmless the Website, its affiliates, and their respective officers, directors, employees, and agents from any claims, damages, or liabilities arising out of the User's use of the Services or any breach of this Agreement. - Governing Law and Jurisdiction 7.1 This Agreement shall be governed by and construed in accordance with the laws of India. Any dispute arising out of or in connection with this Agreement shall be subject to the exclusive jurisdiction of the courts in [Specify the jurisdiction]. - Changes to the Agreement 8.1 The Website reserves the right to modify or update this Agreement at any time without prior notice. The User's continued use of the Services after any changes constitutes acceptance of the modified Agreement. --- # Privacy Policy Source: https://adityaarsharma.com/privacy-policy/ Last updated: 27 June 2023 Aditya Ranjan Sharma ("the Website," "we," "us," or "our") is committed to protecting the privacy and personal information of our users ("User" or "you"). This Privacy Policy outlines how we collect, use, disclose, and protect the information you provide to us when accessing and using our website [Your Website Link] ("the Website"). - Information We Collect 1.1 Personal Information: We may collect personal information such as your name, email address, contact information, and other relevant details when you voluntarily provide it to us through forms, registrations, or communications on the Website. 1.2 Usage Information: We may collect non-personal information about your interactions with the Website, such as your IP address, browser type, device information, and other similar analytics data. This information is collected using cookies and similar technologies. - Use of Collected Information 2.1 We use the collected information for the following purposes: - To provide and maintain the services offered on the Website. - To communicate with you and respond to your inquiries, requests, or feedback. - To personalize and enhance your experience on the Website. - To send you promotional or marketing communications (where you have consented to receive such communications). - To analyze usage trends, improve the Website's functionality, and for statistical purposes. - Disclosure of Information 3.1 We may disclose your personal information in the following circumstances: - To trusted third-party service providers who assist us in operating our website and delivering the services, subject to their agreement to keep the information confidential. - To comply with applicable laws, regulations, or legal processes, or to respond to lawful requests from public authorities. - To protect and defend our rights, property, or safety, as well as the rights, property, or safety of our users or others. - Data Security 4.1 We take reasonable measures to protect the information we collect from unauthorized access, alteration, or disclosure. However, no method of transmission over the internet or electronic storage is completely secure, and we cannot guarantee absolute security of your information. - Third-Party Links 5.1 The Website may contain links to third-party websites or services that are not owned or controlled by us. We are not responsible for the privacy practices or the content of such third-party websites. We encourage you to review the privacy policies of those websites before providing any personal information. - Your Rights 6.1 You have the right to access, update, correct, or delete your personal information held by us. You may also withdraw your consent to the processing of your personal information at any time. To exercise these rights or if you have any questions or concerns regarding your privacy, please contact us at [Your Email Address]. - Changes to the Privacy Policy 7.1 We reserve the right to update or modify this Privacy Policy at any time. Any changes will be effective immediately upon posting the revised Privacy Policy on the Website. We encourage you to review this page periodically to stay informed about how we collect, use, and protect your information. --- # Elementor vs Gutenberg in 2026: An Honest Read for People Who Ship Source: https://adityaarsharma.com/elementor-vs-gutenberg-2026/ Here is the function that decides how much CSS a stock WordPress 7.1 page loads, from `wp-includes/script-loader.php`: ![WordPress ships 140,761 bytes of block CSS on every page by default, whether or not the page contains a single block.](https://adityaarsharma.com/wp-content/uploads/2026/09/532b46af-9bba-4bae-beb8-ecdcbd9b498d_2912x1632-scaled.png)`function wp_should_load_separate_core_block_assets() { if ( is_admin() || is_feed() || wp_is_rest_endpoint() ) { return false; } return apply_filters( 'should_load_separate_core_block_assets', false ); }` ### What the default actually ships ![WordPress Developer Resources page for wp_should_load_separate_core_block_assets()](https://adityaarsharma.com/wp-content/uploads/2026/09/db417d1e-3ecd-4219-b8a6-cdc7dba541c1_2880x1800-scaled.png)developer.wordpress.org, wp_should_load_separate_core_block_assets(). Screenshot taken 3 September 2026.Default false. False means WordPress enqueues one combined `wp-block-library` stylesheet on every front-end page, covering every core block, whether or not the page contains any. That file is 140,761 bytes in 7.1, 19,113 gzipped. When the filter returns true, the equivalent baseline file is `common.min.css` at 4,203 bytes, and per-block styles load only for blocks actually rendered. Byte sizes read from content-length on the published files, 2 September 2026. The enqueue is unconditional, registered in `wp-includes/default-filters.php` as `add_action( 'wp_enqueue_scripts', 'wp_common_block_scripts_and_styles' )`, and that function calls `wp_enqueue_style( 'wp-block-library' )` with no check on whether the page uses blocks. ### The two numbers side by side For comparison, Elementor 4.2.4's entire front-end stylesheet is 54,666 bytes, 7,269 gzipped. So on the default configuration of both, the block editor's baseline CSS is larger than the page builder's. ![Front-end CSS bytes: Elementor 54,666 against core common.min.css 4,203](https://adityaarsharma.com/wp-content/uploads/2026/09/111565d8-74a7-4483-b195-cf39c2306150_2912x1632-scaled.png)Byte sizes read from content-length on WordPress 7.1 and Elementor 4.2.4.Gutenberg is lighter than Elementor as a potential, not as a shipped default, and almost nobody flips the filter. That is the level this comparison usually never reaches, and it is the reason a feature-tick list is useless. I build on both. Here is where each one genuinely wins and what each one charges you later. ## Where Gutenberg genuinely wins ### Your content stays content A Gutenberg page is HTML in `post_content`, annotated with HTML comments. Turn every plugin off, switch to a bare theme, and the page still renders as readable, structured markup. Headings are still headings, paragraphs are still paragraphs, and a search engine or a migration script can read all of it. An Elementor page is JSON in the `_elementor_data` post meta, verified in `core/base/document.php`: The constants are declared in `core/base/document.php`: `TYPE_META_KEY` is `_elementor_template_type`, `PAGE_META_KEY` is `_elementor_page_settings`, `ELEMENTOR_DATA_META_KEY` is `_elementor_data`, and there is a `BUILT_WITH_ELEMENTOR_META_KEY` alongside them. Deactivate the plugin and `post_content` on those pages is usually empty or a stub. Nothing renders. This is not a criticism of the architecture, it is what a builder has to do to give you a live visual editor. It is a fact you should price in at the start of a project rather than discover in year three. Count your exposure in one query, swapping the prefix for yours: `wp db query "SELECT COUNT(*) AS elementor_documents FROM wp_postmeta WHERE meta_key = '_elementor_data' AND meta_value NOT IN ('', '[]');"` ### One place for design tokens A block theme's `theme.json` holds colours, typography, spacing and layout in one versioned file that you can put in git, diff, and copy between projects. Elementor's equivalent is the site kit, which is a post in the database. Both work. Only one of them shows up in a pull request. ### No licence, no vendor Theme Builder, popups, forms and the whole custom CSS surface in Elementor are Pro features. Everything the block editor does is in core. For a site you hand to a client who will not renew anything, that difference compounds for as long as the site exists. Core also keeps moving. The direction of travel in [what WordPress 7.0 changed and why so few people noticed](https://adityaarsharma.com/wordpress-7-0-is-the-biggest-thing-to-happen-since-gutenberg-and-nobodys-talking-about-it/) is a good indication of how much of what people currently buy a builder for is drifting into core. ## Where Gutenberg costs you later ### Block markup is a moving target Blocks carry a version and a deprecation chain. Core changes a block's saved markup, ships deprecation handlers so old posts still parse, and your custom CSS written against the old class names quietly stops matching. ### The same problem on the other side The parallel on the other side is exact: Elementor deleted `.elementor-inner`, `.elementor-row` and `.elementor-column-wrap` in 3.0, `.elementor-image` and `.elementor-text-editor` in 3.2, `elementor-section-wrap` in 3.6, and made all of it non-optional in 3.19. Neither tool protects CSS you wrote against its internal markup. ### The controls you expect are not always there Which block exposes which control depends on the block, on `theme.json`, and on what the active theme opted into. This is why [the colour and underline options go missing in the WordPress editor](https://adityaarsharma.com/fix-missing-color-and-underline-font-options-in-wordpress/) on some sites and not others, and the answer is a theme support declaration rather than anything wrong with the editor. Elementor has the opposite behaviour: every element gets the same styling panel regardless of theme, which is less principled and more predictable. ### Anything beyond a block needs real code Adding a copy button to a code block, changing a search placeholder, styling a specific archive: all of it is PHP, JavaScript and CSS you write and maintain. That is fine when you write code. [Adding a copy-code button to Gutenberg code blocks](https://adityaarsharma.com/how-to-add-copy-code-button-to-wordpress-code-blocks/) and [changing the WordPress search placeholder text without a plugin](https://adityaarsharma.com/how-to-change-search-placeholder-text-in-wordpress/) are both about twenty lines, and both are twenty lines a non-developer on your team cannot maintain. ## Where Elementor genuinely wins ### One styling model, applied everywhere Every element gets the same Advanced tab: margin, padding, positioning, z-index, responsive controls per property, custom CSS at element level. You are not looking up which block supports which `supports` flag. For a marketing site where the pages are built once and edited weekly by someone who does not write CSS, that consistency is the whole product. ### Responsive control Responsive control is the sharpest example. Elementor gives per-breakpoint values on individual properties and up to six custom breakpoints through the `additional_custom_breakpoints` feature, which is stable and active by default. Doing the same in Gutenberg means writing media queries, and [media queries in WordPress that do not take effect](https://adityaarsharma.com/how-to-fix-media-query-not-working-in-wordpress/) are a whole category of support ticket on their own. ### Theme Builder replaces template PHP Headers, footers, single templates, archives and 404s built visually with display conditions, on any theme, without a child theme and without touching a template file. ### Only on block themes Block themes now do this too through the Site Editor, but only on block themes. Elementor does it on the classic theme a client bought in 2019 and will not let you replace. That is not a small thing in agency work. It also removes one of the standard reasons to make a child theme in the first place, which is worth reading properly in [whether you actually need a child theme with Elementor](https://adityaarsharma.com/do-you-need-a-child-theme-with-elementor/) before you scaffold one out of habit. ## Where Elementor costs you later - **Portability.** The JSON in `_elementor_data` is only meaningful to Elementor. There is no export path to plain HTML that survives.- **Structural features that cannot be safely reversed.** The Container feature's own deactivation message in the source says container-based content will be hidden from your site and may not be recoverable in all cases.- **Generated CSS as an operational surface.** Every document compiles to `wp-content/uploads/elementor/css/post-.css`. That directory is state your deploys and your CDN have to think about.- **Markup that changes on a schedule you do not set.** The wrapper removals above, plus the `e_optimized_markup` feature, whose own description warns it might require updating custom CSS or JS and cause compatibility issues with third-party plugins. ## Dynamic behaviour: two models that do not meet Once a page needs to do something rather than just look like something, the two stacks stop being comparable at all. WordPress has shipped the Interactivity API since 6.5.0, and it is present in 7.1 as `WP_Interactivity_API` under `wp-includes/interactivity-api/`. It is a directive system written into the markup itself, with a fixed set of processors: The processors are registered by attribute name: `data-wp-interactive`, `data-wp-router-region`, `data-wp-context`, `data-wp-bind` and the rest, each mapped to its own processor function. ### Block Bindings, the other half Alongside it, the Block Bindings API, `WP_Block_Bindings_Registry`, also since 6.5.0, connects a block attribute to a source such as post meta without writing a dynamic block. Between them, an interactive filtered listing is now a core-only build with server-rendered markup and no framework. ### Elementor's handler model Elementor's model is per-widget JavaScript handlers, and since 3.1.0 those load lazily and resolve asynchronously: The call is `elementorFrontend.elementsHandler.getHandler( 'accordion.default' ).then( handler => ... )`, a promise rather than a direct return. The older synchronous `getHandlers()` was deprecated at the same release. If you are extending a widget and your code assumes the handler is there when the DOM is ready, that is a real difference and it bites third party addons more than it bites site builders. Neither model is better in the abstract. They aim at different people. The Interactivity API assumes you write code and want the behaviour in version control. Elementor's assumes you want a widget that already behaves, and gives you a hook if you do not. ![7,269 gzipped bytes of Elementor](https://adityaarsharma.com/wp-content/uploads/2026/09/cc8c489c-6c31-4755-8c78-affb8bca43ae_2400x2400.png)Elementor 4.2.4 front-end stylesheet, gzipped, measured from content-length. ## The performance caveat, said honestly Flipping the filter at the top of this post is not free money. With separate assets on, WordPress ships a small baseline plus one stylesheet per block type rendered. On a page using three block types that is a clear win. ### Where flipping the filter loses On a long editorial page using fifteen, you have traded one cacheable 140KB file for a baseline plus fifteen small requests, and on a repeat visit the single cached file may well have been better. WordPress added `wp_should_load_block_assets_on_demand()` in 6.8.0 to refine exactly this, and its return value depends on the same filter. So the question is per-site and needs measuring, which is the point of the check at the end of this post. There is one detail here that says more about the real world than any benchmark. ### The feature that says the most Elementor ships a performance feature called Optimized Gutenberg Loading, documented on 23 June 2024, whose entire job is to dequeue unused Gutenberg block editor scripts and styles on the front end. A page builder shipping a feature to undo the block editor's default asset loading is a reasonable summary of where this argument actually sits. ## What the comparison actually reduces to Weight is close enough on defaults that it should not decide anything. The real axis is who maintains the page after you, and how long the page has to outlive the tool that made it. | Situation | What I would use | Why | | --------- | ---------------- | --- | | Marketing pages a non-developer edits weekly | Elementor | The editing cost dominates. Consistent controls beat clean markup here. | | Blog, documentation, knowledge base | Gutenberg | High page count, low design variance, and the content must stay readable HTML for a decade. | | Client site, agency hands it over, no retainer | Gutenberg or a coded theme | No licence to renew, nothing to break when a subscription lapses. | | Templated archives on a classic theme you cannot replace | Elementor Theme Builder | The alternative is rebuilding the theme. | | A page that has to survive the next ten years | Gutenberg or plain templates | `post_content` is the only storage here that a future migration can read. | Disclosure, since it is relevant and I am not going to pretend otherwise: I work on product and growth at POSIMYTH, which builds products for both editors. That is why I have no side to pick. It is also why the honest answer on most real projects is both, split by surface, rather than a house standard applied to everything. ## The one check worth running before you argue about this Add this to a must-use plugin on staging, load a page, and compare the network panel before and after: ` Settings > Advanced > REST API**. You get a consumer key and consumer secret, with a scope of read, write or read/write. From [the authentication documentation](https://woocommerce.github.io/woocommerce-rest-api-docs/#rest-api-keys), two facts people miss: - The key inherits the WordPress roles and capabilities of the user you attached it to. A key on an administrator is an administrator.- If that WordPress user is deleted, the key stops working. Keys are not transferred to another user. Over HTTPS you send the pair as HTTP Basic Auth, key as username, secret as password. Over plain HTTP the documentation directs you to OAuth 1.0a one-legged with an HMAC-SHA1 signature, and the docs say HTTPS is recommended where possible rather than required. Read that carefully. The client libraries expose a `query_string_auth` option that puts the key and secret in the URL, which lands them in your access log, your proxy's log, and any Referer header the response triggers. If you are choosing here, choose HTTPS with Basic and never set that option. ### Application passwords, and why I default to them The second mechanism is WordPress core application passwords, available since 5.6, which authenticate against the whole REST API including `wc/v3`. Difference that matters: an application password is revocable per application without deleting the user, and it is visible in the user's own profile screen. A WooCommerce consumer key is a separate list in a separate settings screen that nobody audits. For agent work I default to application passwords for exactly that reason, and I wrote the full credential setup in [running Claude Code against WordPress](https://adityaarsharma.com/running-claude-code-against-wordpress-the-complete-setup/). Neither mechanism gives you a per-endpoint scope. Read/write means write to everything the user can write to. ## Reads: what is safe, and where the ceilings are ### Two hard numbers, both from the source **Pagination caps at 100.** In `class-wc-rest-crud-controller.php`, `per_page` is declared with `'minimum' => 1, 'maximum' => 100`. The default is 10. Ask for 500 and you get a 400, not 500 items. **Batch writes cap at 100 items.** `check_batch_limit()` in `class-wc-rest-controller.php` reads the limit from the `woocommerce_rest_batch_items_limit` filter, defaulting to 100, and returns a `WP_Error` above it. The count is `create` plus `update` plus `delete` combined, not 100 of each. Exceed it and you get HTTP 413 with the code `woocommerce_rest_request_entity_too_large`. It is filterable, and raising it on shared hosting is how you find your PHP memory limit. What that ceiling costs is easy to work out. WooCommerce's own store publishes 1,717 products, a figure I read from the `x-wp-total` header on its Store API on 2 September 2026. At the default `per_page=10` that is 172 requests. At the maximum of 100 it is 18. Ask for 500 and you get an HTTP 400 back, so 18 is the floor. Every list response carries `X-WP-Total` and `X-WP-TotalPages`. Those are not free. They come from the query's found-rows count, which on a large catalogue is a second pass over the result set on every single page request. If you are walking 200 pages you are paying for 200 counts you already know the answer to. ![Bar chart: 172 requests at per_page=10 against 18 requests at per_page=100 to walk 1,717 products.](https://adityaarsharma.com/wp-content/uploads/2026/09/169f61db-63f5-4a0d-b129-a1e74257ebad_2912x1632-scaled.png)Built from the product count and pagination cap in this post, read 2 September 2026. ## The pagination bug that eats records Offset pagination over a set you are also mutating drops rows. It is not a WooCommerce bug, it is arithmetic, and it is the single most common way a bulk script silently misses records. ### How the rows go missing You request page 1 with `orderby=date&order=desc`, get 100 orders, and set them to completed. Your `status=processing` filter now matches 100 fewer rows. You request page 2, and the database gives you rows 101 to 200 of the *new* set, which are rows 201 to 300 of the old one. Rows 101 to 200 are never seen. On a 5,000 order run with a filter on the field you are writing to, you will process roughly half and have no error to show for it. Two fixes, both boring: - **Keyset pagination.** Sort by `id` ascending and page with the last id you saw, not with a page number. WooCommerce supports `?orderby=id&order=asc&offset=` but the cleaner form is to filter on `after` with a date column, or to collect all ids first with `_fields=id` and then iterate that fixed list.- **Collect, then write.** Run the whole read phase into a local file. Write from the file. Now the set you are writing to cannot move under you, and a crashed run is resumable. `# read phase: ids only, cheap, stable for page in $(seq 1 18); do curl -s -u "$CK:$CS" \ "https://example.com/wp-json/wc/v3/products?per_page=100&page=$page&_fields=id,sku,stock_quantity" \ >> /tmp/products.ndjson sleep 1 done` `_fields` is WordPress core, not WooCommerce, and it works on every `wc/v3` endpoint. On a catalogue with long descriptions it is the difference between an 18 megabyte read and a 200 kilobyte one. Use it on every read you do not need the full object for. ## Rate limits: there are none where you expect them WooCommerce [documents rate limiting for the Store API](https://developer.woocommerce.com/docs/apis/store-api/rate-limiting/), and the documented behaviour is narrow: - It is disabled by default and switched on with the `woocommerce_store_api_rate_limit_options` filter.- The default is 25 requests per 10 seconds.- Only `POST` requests are rate limited. `GET` is not. ### No documented limit on wc/v3 There is no equivalent documented limit on the authenticated `wc/v3` API. Your effective limit is your host's: PHP workers, `max_execution_time`, and whatever your WAF decides is abuse. This is worse than a documented limit, because you find it as a 502 halfway through a write batch with no idea which items committed. So build the limit yourself. One request at a time with a one second gap, exponential backoff on 429 and 5xx, and an idempotency key of your own in a meta field so a retry can detect that it already ran. ![Stat card: HTTP 413, the status returned above 100 items in one WooCommerce batch request.](https://adityaarsharma.com/wp-content/uploads/2026/09/e1bc9177-0c90-4b68-a873-cb3b91cdade2_2400x2400.png)Built from check_batch_limit() in class-wc-rest-controller.php, read on trunk 2 September 2026. ## The writes that are one-way These are the ones I will not put in an unattended script without a dry run and a confirmation step. ### 1. Order status, because of the emails The hook list at the top of this post is the whole argument. Setting `status` on an order fires the transition hooks, and the transition hooks send mail. If you must do it in bulk, the safe pattern is to unhook the mailer for the duration of your run rather than to hope. If you want per-template control instead of an all-or-nothing switch, the approach in [applying a WordPress email template to only specific emails](https://adityaarsharma.com/apply-wordpress-email-template-to-specific-emails/) is the same hook surface used the other way round. ### 2. Order status, because of the stock Separately from mail, status transitions move inventory. From `includes/wc-stock-functions.php` on trunk: `add_action( 'woocommerce_payment_complete', 'wc_maybe_reduce_stock_levels' ); add_action( 'woocommerce_order_status_completed', 'wc_maybe_reduce_stock_levels' ); add_action( 'woocommerce_order_status_processing', 'wc_maybe_reduce_stock_levels' ); add_action( 'woocommerce_order_status_on-hold', 'wc_maybe_reduce_stock_levels' ); add_action( 'woocommerce_order_status_cancelled', 'wc_maybe_increase_stock_levels' ); add_action( 'woocommerce_order_status_pending', 'wc_maybe_increase_stock_levels' ); add_action( 'woocommerce_order_status_failed', 'wc_maybe_increase_stock_levels' );` The guard is a single flag. `wc_maybe_reduce_stock_levels()` reads `get_stock_reduced()` and returns early if it is already true, so setting completed twice does not double-decrement. But the reverse is not guarded the same way. Move an order processing to pending and back to processing and you have run an increase and a decrease. If a customer bought the last unit in between, your stock is now negative or your oversell is now real. A script that resets orders to pending to reprocess them is doing inventory arithmetic it does not know about. ### 3. Refunds through the API hit the gateway `POST /wp-json/wc/v3/orders/{id}/refunds` with `"api_refund": true` asks the payment gateway to move money. That is not a WordPress record change, it is a Stripe or PayPal API call, and there is no undo endpoint. Build refund automation as a human-triggered single action with the amount on screen, which is what I did in [the one-click EDD refund button inside Fluent Support](https://adityaarsharma.com/fluent-support-one-click-refund-button/), not as a loop. ### 4. Force delete is a real delete `DELETE /wp-json/wc/v3/products/{id}` moves the product to trash. `DELETE /wp-json/wc/v3/products/{id}?force=true` deletes the row. Orders and coupons behave the same way. One query parameter separates recoverable from gone, and a script with `force` hardcoded because the trash was cluttering a test run is a live grenade. ## Safe to script, in a table | Operation | Verdict | Why | | --------- | ------- | --- | | `GET` anything | Safe | No side effects. Cap at `per_page=100`, use `_fields`. | | `PUT /products/{id}` price, description, images | Safe | Fires `woocommerce_update_product` and clears transients. No customer contact. | | `PUT /products/{id}` `stock_quantity` | Mostly safe | Triggers low stock and no stock notification emails to the shop manager at your thresholds. | | `POST /products/batch` | Safe under 100 | 413 above the limit. Whole batch is not atomic, so partial success is normal. | | `PUT /orders/{id}` notes, metadata, addresses | Safe | No transition hook. | | `PUT /orders/{id}` `status` | One way | Sends customer email, moves stock, grants downloads. | | `POST /orders/{id}/refunds` | One way | Calls the gateway when `api_refund` is true. | | `DELETE ...?force=true` | One way | Row is gone. No trash. | | `PUT /settings/{group}/{id}` | Dangerous | Store-wide. Changing a tax or currency setting affects every open cart. | ![The WooCommerce REST API v3 reference on woocommerce.github.io.](https://adityaarsharma.com/wp-content/uploads/2026/09/702e2ce2-b342-49f0-b286-7670486bd888_2800x2000-scaled.png)woocommerce.github.io/woocommerce-rest-api-docs/, screenshot taken 3 September 2026. ## Use webhooks instead of polling, with the failure mode in mind Most scripts that poll `wc/v3/orders` every five minutes should be a webhook. WooCommerce delivers them from `includes/class-wc-webhook.php`, and the mechanics are worth knowing before you rely on one: - Delivery is `POST` via `wp_safe_remote_request()` with `'timeout' => MINUTE_IN_SECONDS` and `'redirection' => 0`. Your endpoint has sixty seconds and must not redirect.- Success is a response code from 200 to 302 inclusive. Anything else counts as a failure.- After more than five consecutive failures the webhook sets its own status to `disabled` and fires `woocommerce_webhook_disabled_due_delivery_failures`. The threshold is filterable with `woocommerce_max_webhook_delivery_failures`.- The user agent is `WooCommerce/{version} Hookshot (WordPress/{version})`, which is what to grep your receiver's log for. ### The auto-disable that catches people Six bad deploys in a row on your listener and the webhook is off, silently, and nothing on the WooCommerce side tells you. Hook that action to an alert on day one. ## The shape of a script I would actually run - **Read phase, ids only, into a file.** Page with `per_page=100` and `_fields=id,sku`, appending to `ids.ndjson`, stopping when a page returns fewer than 100. Resumable, and the set cannot move under you.- **Dry run.** Turn the ids into a `plan.json` that prints the diff and writes nothing. Count the rows before you commit to anything.- **Write phase.** Apply the plan in batches of 100, one batch at a time, one second apart, logging a response code per item. Three properties do the work. The read is separate from the write. The plan is a file a human can open before anything commits. And the write logs a response code per item, so a partial failure is a list of ids to retry rather than a shrug. If you are running this across a lot of client sites rather than one, the operational side is a different problem and [how I manage WordPress sites for clients](https://adityaarsharma.com/how-to-manage-wordpress-websites-for-clients/) and [the WordPress site management tools comparison](https://adityaarsharma.com/best-8-wordpress-management-tools/) cover the parts that scripts do not. ## One thing to do in the next ten minutes Go to WooCommerce > Settings > Advanced > REST API and read the list. For every key, ask which user it belongs to and whether that user is an administrator. Then check whether the description tells you what still uses it. Every store I have looked at has at least one key from an integration that was removed years ago, at full read/write, on an admin account. Revoke the ones you cannot name. A key you cannot explain is a key you cannot rotate. If you want the conference-talk version of the integration side, this one is on the official WordPress channel. https://www.youtube.com/watch?v=bkEeiAABrlc **[Andrew Duncan: WooCommerce REST API integration](https://www.youtube.com/watch?v=bkEeiAABrlc)**, on the WordPress channel. A walkthrough of building against the API from the integrator's side. Verified via the YouTube oEmbed endpoint on 2 September 2026. What the store publishes without a key at all is a separate surface with separate rules, and I went through it in [what a WooCommerce store actually exposes to AI shopping agents](https://adityaarsharma.com/woocommerce-ai-shopping-agents-what-the-store-exposes/). The crawler side of the same question, for the URLs bots should never be walking, is in [preventing add-to-cart URLs from being crawled](https://adityaarsharma.com/how-to-prevent-woocommerce-add-to-cart-dynamic-urls-from-crawling/). ## Resources - [WooCommerce REST API documentation](https://woocommerce.github.io/woocommerce-rest-api-docs/), the v3 reference- [developer.woocommerce.com REST API overview](https://developer.woocommerce.com/docs/apis/rest-api/), requirements and authentication- [class-wc-rest-controller.php](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-controller.php), the batch limit and the 413- [wc-stock-functions.php](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/wc-stock-functions.php), every status hook that moves stock- [class-wc-emails.php](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/class-wc-emails.php), the transitions that send mail- [class-wc-webhook.php](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/class-wc-webhook.php), delivery timeout and auto-disable- [Store API rate limiting](https://developer.woocommerce.com/docs/apis/store-api/rate-limiting/)- [WordPress REST API global parameters](https://developer.wordpress.org/rest-api/using-the-rest-api/global-parameters/), where `_fields` comes from --- # Running a Self-Hosted Crawler: Crawl4AI and SearXNG in Docker Source: https://adityaarsharma.com/crawl4ai-searxng-self-hosted-crawler/ Here is the health check on the crawler I use for every piece of research on this site, run this afternoon: `crawl health` answers with `{"status":"ok","version":"0.9.2"}` from Crawl4AI and a 200 from SearXNG, alongside the memory line that matters: 1.172GiB. ![Bar chart of container memory ceilings: falkordb and n8n with no limit, crawl4ai upstream 4096 MiB, my limit 1200 MiB, in use 606 MiB](https://adityaarsharma.com/wp-content/uploads/2026/09/4801d629-b094-4251-9e55-570794e3058d_2912x1632-scaled.png)From `docker stats` and `docker inspect` on my own server, 2 September 2026.That `1.172GiB` is not a Docker default and it is not what the project recommends. It is a ceiling I set, and it is **1,258,291,200 bytes**, which is 1,200 MiB. Crawl4AI's own compose file asks for 4G with a 1G reservation. I am running the thing at 30 percent of what its authors specify, on a box that also serves [my self-hosted analytics](https://adityaarsharma.com/plausible-self-hosted-vps-cost/), an n8n instance and a graph database. This post is the honest version of that setup: the real images, the real commands, the two failure modes that actually bit me, and the one I was wrong about. ## Crawl4AI: the image, the tag, the command ![Docker Hub page for unclecode/crawl4ai showing the latest tag, 1M+ pulls and compressed image sizes of 1.54 GB for amd64](https://adityaarsharma.com/wp-content/uploads/2026/09/ace3aa77-8ca9-4941-aedb-3b177d5463f0_2880x1800-scaled.png)unclecode/crawl4ai on Docker Hub. Screenshot taken 2 September 2026 from hub.docker.com/r/unclecode/crawl4ai/tags. ### The image, and what it weighs The image is `unclecode/crawl4ai`. Docker Hub shows 1M+ pulls and, for the `latest` tag, a compressed size of 1.54 GB on linux/amd64 and 2.09 GB on linux/arm64 (read 2 September 2026). Uncompressed on my server it is larger: `docker image inspect unclecode/crawl4ai:latest --format '{{.Size}}'` returns 4232061763 bytes. `docker system df` put total images on that host at 9.97 GB. Just under 4 GB for one image, and 9.97 GB of images on a 150 GB disk. That is the first cost nobody mentions: Crawl4AI ships a full Playwright Chromium inside the container, which is the whole point of it and also the whole weight of it. The run command from the project README, unedited: `docker pull unclecode/crawl4ai:latest docker run -d -p 11235:11235 --name crawl4ai --shm-size=1g unclecode/crawl4ai:latest # dashboard: http://localhost:11235/dashboard # playground: http://localhost:11235/playground # health: http://localhost:11235/health` ### The two load-bearing flags Two things in that line are load-bearing. `--shm-size=1g` is there because Chromium puts renderer shared memory in `/dev/shm`, and Docker's default is 64 MiB, which is nowhere near enough for a browser. And there is no memory limit on it at all, which is fine on a laptop and is a decision you have to make yourself on a shared server. ### The compose file is the safer copy The project's `docker-compose.yml` is the more careful version of the same thing, and it is worth copying rather than the one-liner: It sets `shm_size: "1gb"`, `cap_drop: ALL`, `security_opt: no-new-privileges:true`, `read_only: true`, a PID cap and a named user. A read-only root filesystem, all capabilities dropped, a PID cap and a named user. You are running a headless browser that fetches arbitrary internet content and parses it. Treat it like that. ## Where my box differs from the docs, and what it costs Mine does not match. Here is the gap, straight from `docker inspect`: `docker inspect crawl4ai` with a `--format` string for `HostConfig.Memory`, `HostConfig.ShmSize` and `HostConfig.CpuQuota` prints the gap in one line. - memory: **1,200 MiB** against a documented 4,096 MiB limit and 1,024 MiB reservation- shm: **512 MiB** against a documented 1,024 MiB- cpus: 1.5 of the host's cores ### Why the shm number matters more than it looks ![Crawl4AI memory ceiling: 1,200 MiB set against 4,096 MiB in the project compose file](https://adityaarsharma.com/wp-content/uploads/2026/09/41d70bde-d9b4-4c80-b225-8394ce7c9129_2912x1632-scaled.png)docker inspect on my own host against the project compose file, 2 September 2026.The mechanism that makes the shm number more interesting than it looks: on cgroup v2, which is what this host uses (`stat -fc %T /sys/fs/cgroup/` returns `cgroup2fs`), tmpfs pages are charged to the cgroup that faults them in. So `/dev/shm` is not a separate allowance sitting outside the memory limit. Every megabyte Chromium writes there comes out of the same 1,200 MiB. Fill the 512 MiB shm and you have 688 MiB left for Python, the Playwright supervisor and every browser process. That is why the client-side pacing in my wrapper is not politeness theatre. Two concurrent fetches, a randomised 1.5 to 3.0 second gap per domain, and a hard ceiling of 300 requests per domain per day. Raise the concurrency on this container and the failure is not slow, it is a kill. ## The thing I got wrong about the blast radius I assumed for months that an out-of-memory event in the crawler would take the box with it. It will not, and the reason matters if you are sizing a shared server. ### A cgroup limit is a wall in one direction A cgroup memory limit is a wall in one direction. When processes inside the container exceed it, the kernel reclaims and then kills inside that cgroup. The container dies. The host does not. So the container I capped is the one that fails safely. ### The containers I did not cap The dangerous ones on that host are the containers I did not cap: `docker stats --no-stream` told the story. SearXNG sat at 82.36MiB against a 4GiB limit. The containers I never capped reported their usage against 7.569GiB. `7.569GiB` is not a limit. That is Docker reporting total host memory because no limit was set. FalkorDB and n8n can each grow until the host OOM killer starts choosing victims by score, and it will not necessarily choose them. FalkorDB is on that box because it is one of the graph backends Graphiti supports, which is why it turned up when I [benchmarked seven AI memory tools](https://adityaarsharma.com/ai-memory-tools-compared/). It is a database, and databases are the ones you cap first. Add the swap figure from that host, **1,808 MiB used of 2,047**, and you have a machine with nowhere left to spill. So the correction to the thing I believed: cap everything, and cap the databases first. The crawler with a limit is the well-behaved one. ## SearXNG: the repository most guides still link is archived ![GitHub page for searxng/searxng-docker showing an archived banner dated Mar 28, 2026 and a deprecation warning in the README](https://adityaarsharma.com/wp-content/uploads/2026/09/d69bb15a-4c35-4126-93d3-22e3ea8d4d32_2880x1800-scaled.png)searxng/searxng-docker, archived by the owner on 28 March 2026. Screenshot taken 2 September 2026. ### The 3.3k star repository is archived If you search for how to run SearXNG in Docker you land on `searxng/searxng-docker`, 3.3k stars, and clone it. It was archived on **28 March 2026** and its README now contains two lines and a deprecation warning. The repository has exactly two files left in it: `LICENSE` and `README.md`. The current instructions live in the project's own documentation, and the compose template moved into the main repository under `container/`. Create `./searxng/core-config/`, then pull two files with `curl -fsSL -O` from the main `searxng/searxng` repository: `container/docker-compose.yml` and `container/.env.example`. Copy the example before you start anything. The compose file declares `env_file: ./.env`, so Compose does not fall back to defaults when that file is absent. It fails. Run `cp -i .env.example .env` first. Every value in it ships commented out, which is fine. The image tag falls back with `${SEARXNG_VERSION:-latest}` and the port with `${SEARXNG_PORT:-8080}`. Uncomment `SEARXNG_HOST` only when you want it bound somewhere other than every interface. Read from the searxng/searxng master branch, 3 September 2026. The template it fetches is short and names two images: `docker.io/searxng/searxng:latest` for the core service, and `docker.io/valkey/valkey:9-alpine` for the cache. ### Valkey, not Redis Note `valkey/valkey:9-alpine` rather than Redis. If you are following a 2024 guide that starts a `redis:alpine` sidecar, that is the tell that the guide is stale. For a single container, the docs give `docker run --name searxng -d -p 8888:8080` with the config and data directories bind-mounted from `./searxng/config/` and `./searxng/data/`, and that is what I actually run. Mine binds to loopback only, which `docker port` confirms: `8080/tcp -> 127.0.0.1:8888`. If you are going to disable the request limiter, as I have, that binding is the thing standing between you and a public open proxy. ## The JSON API is off by default, and the failure is a 403 Every wrapper you write against SearXNG will want `format=json`. It is disabled out of the box. I ran a clean container to see exactly what happens rather than describe it. The container was `docker run -d --rm --name searxng-default -p 8877:8080 searxng/searxng:latest`, then asked it the same query twice with `curl -s -o /dev/null -w '%{http_code}'`. Plain HTML came back 200. Adding `&format=json` came back 403. ### What the 403 actually means A 403 with an HTML body, from a server that answered the identical query with a 200 one second earlier. If you are debugging through a client library that only surfaces status codes, that reads like an auth problem, and it is a config problem. The cause is three lines in the shipped `settings.yml`: `84: # formats: [html, csv, json, rss] 85: formats: 86- - html`Add `- json` under `formats:` in your own `/etc/searxng/settings.yml` and restart. That is the whole fix. ## The failure that produces false research findings This is the one worth the price of the post, because it does not look like a failure. SearXNG suspends an engine after errors, and the durations are in the documented defaults: `search: ban_time_on_fail: 5 max_ban_time_on_fail: 120 suspended_times: SearxEngineAccessDenied: 86400 SearxEngineCaptcha: 86400 SearxEngineTooManyRequests: 3600 cf_SearxEngineCaptcha: 1296000 cf_SearxEngineAccessDenied: 86400 recaptcha_SearxEngineCaptcha: 604800`An engine that answers with 429 is out for **3,600 seconds**. One that returns a Cloudflare captcha is out for **1,296,000 seconds**, which is fifteen days. Google and Bing and Brave all fingerprint datacenter IP ranges, so a SearXNG instance on a VPS collects these suspensions steadily. ### Why a dead index looks like a real negative Here is the failure mode. When every enabled engine is suspended, SearXNG does not error. It returns **HTTP 200 with an empty results array**. Your script sees a healthy response and zero results, and if you are using that script for research you will write down that a thing does not exist when the truth is that your search engine was asleep. My own `settings.yml` carries the scar and the date: - Mojeek, Marginalia, Yep and Right Dao, kept because they run their own indexes and do not need a residential IP- the comment above the list in my own file records why: independent crawlers do not run aggressive anti-bot, so a datacenter IP is fineMojeek, Marginalia, Yep and Right Dao run their own indexes rather than proxying a major engine, so they have no commercial reason to block a datacenter IP. Coverage is narrower than Google. Availability is far better, and for research a narrow index that answers beats a wide one that is suspended. ### The control query rule The operational rule that falls out of this: **run a control query with a known-good answer before you record any negative finding.** If the control returns nothing, your crawler is broken, not the web. I use this on every research run, and it has caught the empty-200 twice. That is the same shape as the malware case I wrote up when [the scanner reported the site clean while it served spam to Googlebot](https://adityaarsharma.com/the-scanner-said-clean-the-site-was-serving-spam-to-googlebot/). A tool answering confidently is not the same as a tool being right. The same discipline applies whenever you scrape structurally rather than by hand. I wrote up the sitemap version of it in [extracting links from a website using its sitemap](https://adityaarsharma.com/how-to-extract-links-from-websites-using-sitemap-in-sheets/), and the crawl-control side of it in [stopping WooCommerce add-to-cart URLs from being crawled](https://adityaarsharma.com/how-to-prevent-woocommerce-add-to-cart-dynamic-urls-from-crawling/). ## What this replaces, in money Two containers on a server I already pay for, against the hosted equivalents, all read 2 September 2026: - Firecrawl lists a Free tier at 1,000 credits a month, **Standard at $83/month** for 100,000 credits, and **Scale at $599/month** for 1,000,000. Credits do not roll over on self-serve plans.- Brave's Search API page lists **$5 per 1,000 requests**, with $5 of free credits every month.- Crawl4AI is Apache-2.0 (81,087 stars). SearXNG is AGPL-3.0 (36,426 stars). Both figures from the GitHub API, 2 September 2026.The self-hosted side is not free. It is roughly 4 GB of image, 1.2 GB of RAM under a cap, whatever your VPS costs, and the two failure modes above. What it is not is metered, and for research work that runs hundreds of fetches in an afternoon, the meter is the thing that changes your behaviour. The full arithmetic, with backups and monitoring priced in, is in [what self-hosting actually costs](https://adityaarsharma.com/self-hosting-vs-saas-real-cost/). ## Resources - [unclecode/crawl4ai](https://github.com/unclecode/crawl4ai), Apache-2.0. The README carries the run command and the dashboard endpoints.- [The Crawl4AI compose file](https://raw.githubusercontent.com/unclecode/crawl4ai/main/docker-compose.yml), which is the hardened version of that command.- [SearXNG container installation docs](https://docs.searxng.org/admin/installation-docker.html), the page that replaced the archived repository.- [SearXNG search settings reference](https://docs.searxng.org/admin/settings/settings_search.html), where the `formats` and `suspended_times` defaults are documented.- [The current SearXNG compose template](https://raw.githubusercontent.com/searxng/searxng/master/container/docker-compose.yml), core plus valkey.- [Firecrawl pricing](https://www.firecrawl.dev/pricing) and [the Brave Search API page](https://brave.com/search/api/), for the comparison. ## One thing to do now Run `docker stats --no-stream` on whatever box you are self-hosting on and read the right-hand side of the memory column. Any container reporting your full host RAM as its limit has no limit. Give the databases one first, then the crawler. It takes one line per service and it decides which process dies when the box runs out. --- # Self-Hosting Your Own Analytics: Plausible on a VPS, and What It Actually Costs Source: https://adityaarsharma.com/plausible-self-hosted-vps-cost/ I ran `du -sh` against the ClickHouse volume behind my self-hosted Plausible install this morning and got **8.6G**. The site it measures gets a few hundred pageviews a day. So I asked ClickHouse what was actually in there. `SELECT database, table, formatReadableSize(sum(bytes_on_disk)) AS size, sum(rows) AS rows FROM system.parts WHERE active GROUP BY database, table ORDER BY sum(bytes_on_disk) DESC`Run it with `docker exec clickhouse-client --query`. ![Bar chart of ClickHouse table sizes: asynchronous_metric_log 3.43 GiB, metric_log 2.70 GiB, trace_log 1.75 GiB, query_log 390 MiB, plausible.events_v2 2.45 MiB](https://adityaarsharma.com/wp-content/uploads/2026/09/b3657f31-7e6a-42a0-8886-0a9bca9fbd6f_2912x1632-scaled.png)Sizes from `system.parts` on my own instance, queried 2 September 2026.The analytics I wanted are the bottom two rows. `events_v2` plus `sessions_v2` is **4.36 MiB**. Everything above them is ClickHouse writing telemetry about ClickHouse. Call it 8.3 GB of self-description for 4 MiB of pageviews, accumulated on a box that `uptime` reports as up 121 days (run 2 September 2026). ![ClickHouse volume: 8.6 GB total against 4.36 MiB of analytics tables](https://adityaarsharma.com/wp-content/uploads/2026/09/d74df4e3-598f-496b-94fd-830ed68b2c2e_2912x1632-scaled.png)system.parts on my own instance, queried 2 September 2026.This is not a ClickHouse bug and it is not a Plausible bug. It is what you get when you assemble the stack from a tutorial instead of from Plausible's own reference compose file, because that file mounts a ClickHouse config which switches those tables off and mine does not mount it. I checked, and the missing file is mine. ## The compose file Plausible actually ships Plausible Community Edition has its own repository at [github.com/plausible/community-edition](https://github.com/plausible/community-edition). The current tag is `v3.2.1` and the README tells you to clone that exact tag rather than the default branch (read 2 September 2026): `git clone -b v3.2.1 --single-branch https://github.com/plausible/community-edition plausible-ce`. The repository holds four things, a `clickhouse/` directory, `compose.yml`, the licence and the README. ### The three images it pins Three images, with the tags the project pins itself: - `postgres:16-alpine`, the application database (users, sites, settings)- `clickhouse/clickhouse-server:24.12-alpine`, the event store- `ghcr.io/plausible/community-edition:v3.2.1`, Plausible itself ### The block every third-party writeup drops And here is the block that goes missing from every third-party writeup I have read. It is the `volumes:` list on the ClickHouse service: `plausible_events_db: image: clickhouse/clickhouse-server:24.12-alpine volumes: - event-data:/var/lib/clickhouse - event-logs:/var/log/clickhouse-server - ./clickhouse/logs.xml:/etc/clickhouse-server/config.d/logs.xml:ro - ./clickhouse/ipv4-only.xml:/etc/clickhouse-server/config.d/ipv4-only.xml:ro`Four bind mounts. Drop them and the container still starts, still serves, still records pageviews correctly. Nothing tells you anything is wrong for about six months. ## What logs.xml does, and why it matters The whole file is 828 bytes. It sets the logger level to `warning` with console output on, then redefines `query_log`, `trace_log`, `asynchronous_metric_log` and `metric_log` with `replace="1"`, a 7500 millisecond flush interval and a TTL on each. ### Why the logs grow and the events do not The mechanism is worth understanding because it explains the shape of my 8.6 GB exactly. ClickHouse merges every XML file it finds in `config.d/` into one configuration tree. `remove="remove"` deletes that node from the merged tree, so the table is never created and the background thread that feeds it never starts. `replace="1"` on `query_log` swaps the default definition for one carrying `TTL event_date + interval 30 day`, so query history is kept for a month and then the parts are dropped whole. Without the file, ClickHouse defaults apply and none of those tables have a TTL. `asynchronous_metric_log` samples several hundred server metrics on a fixed interval and appends a row per metric per sample, forever. That is where 5,440,544,186 rows came from on a site nobody visits much. Nothing prunes it, because nothing was told to. ### How I dated my own install My instance went up on **27 October 2025**. I can date it because the one-shot `plausible-run-*` containers from that first `docker compose run` are still in `docker ps -a` with a 2025-10-27 start time, and because I published the walkthrough the same day. https://www.youtube.com/watch?v=514j1YCF-Z8That is my own video, "How to Self-Host Plausible on VPS with RunCloud - Google Analytics Alternative!", on the Aditya Sharma channel, published 27 October 2025. It covers the RunCloud side: creating the web application, pointing the domain, and putting the reverse proxy in front of port 8000. It does not cover the ClickHouse config files, which is the gap this post exists to close. ## The RAM numbers, theirs and mine The Plausible CE README states two hardware prerequisites in plain text. The CPU must support the **SSE 4.2** or **NEON** instruction set, which is a ClickHouse requirement and rules out some older and some very small ARM instances. And: *at least 2 GB of RAM is recommended for running ClickHouse and Plausible without fear of OOMs*. ### My ceilings, and what they hold My compose sets ceilings below that, deliberately, because the box runs other things: - `plausible`: memory limit 512M- `clickhouse`: memory limit 512M- `db`: memory limit 256MTotal 1.25 GB against a recommended 2 GB. Here is what those containers were actually holding when I checked, from `docker stats --no-stream` on 2 September 2026: - `aditya-analytics-plausible-1`: 116.5MiB of 512MiB, 22.75 per cent- `aditya-analytics-clickhouse-1`: 318.9MiB of 512MiB, 62.28 per cent- `aditya-analytics-db-1`: 58.37MiB of 256MiB, 22.80 per centClickHouse sitting at 62 percent of a ceiling the vendor says should be higher is the line I would watch. `docker inspect` shows that container has restarted five times. `State.OOMKilled` is false for the current run, so I cannot claim those restarts were memory pressure, and I am not going to. What I can say is that the headroom is thin and the project told me it would be. ### The number on the host that is worse The other number on that host is worse. `free -m` reports 7,750 MiB of RAM with 4,386 in use, and swap at **1,808 MiB of 2,047**. That is a machine already leaning on disk, which is the state in which an unexpected 3 GB log table stops being a curiosity. ## What the VPS costs, from a page that renders I wanted to quote Hetzner here because that is where this box lives. Their price grid is injected client-side, so it does not appear in the HTML a crawler receives, and I am not going to type a number I could not read from the source. DigitalOcean publishes the same class of machine on a server-rendered page, so that is the table I can show you. ![DigitalOcean Basic Droplets pricing table showing monthly prices from $4 to $96](https://adityaarsharma.com/wp-content/uploads/2026/09/3a88b585-5a80-4458-9d84-898fe6a23120_2880x1800-scaled.png)DigitalOcean Basic Droplets, regular CPU. Screenshot taken 2 September 2026 from digitalocean.com/pricing/droplets.- 2 GiB / 1 vCPU / 50 GiB SSD: **$12.00** per month- 4 GiB / 2 vCPUs / 80 GiB SSD: **$24.00** per month- 8 GiB / 4 vCPUs / 160 GiB SSD: **$48.00** per month ### Reading the price grid The $12 line meets the 2 GB the README asks for and nothing more. If the box does one job, that is the honest entry price. Mine runs seven containers, including [a self-hosted crawler and search stack](https://adityaarsharma.com/crawl4ai-searxng-self-hosted-crawler/), so it sits well above that line, which is exactly why the marginal cost of adding Plausible to it felt like zero and why the 8.6 GB went unnoticed for ten months. ### Disk is the quiet part Disk is the quiet part. `df -h /` on my host shows 150G total with 73G used. The ClickHouse volume is 8.6G of that, and it grew without any event volume growing. If you size a $12 Droplet at 50 GiB and forget the logs config, you have a slow-motion disk-full incident scheduled for some point in year four. ## Against Plausible Cloud, honestly ![Plausible Cloud pricing at the 10k monthly pageviews slider position, showing Starter $9, Growth $14, Business $19 per month](https://adityaarsharma.com/wp-content/uploads/2026/09/d86025bc-944b-4315-beb5-60a5c8453b5f_2880x1800-scaled.png)Plausible Cloud, monthly billing, slider at its default 10k monthly pageviews. Screenshot taken 2 September 2026 from plausible.io.At the default slider position of 10,000 monthly pageviews, Plausible Cloud is $9 a month for Starter (one site, 3 years of retention), $14 for Growth (3 sites), and $19 for Business (10 sites, 5 years of retention, Stats API at 600 requests per hour). Annual billing on the same page shows $90, $140 and $190. ### Where Cloud wins So for one small site, Cloud costs less than a dedicated $12 Droplet the moment you price an hour of your own time into the self-hosted side. Say that plainly, because most self-hosting posts will not. Plausible is AGPL-3.0 and the company funds the community edition from cloud subscriptions, which they say on the repository page. ### Where self-hosting wins Self-hosting starts winning in three situations, and only three that I can defend: - The server already exists for other work, so the marginal cost is disk and RAM rather than a new bill.- You want raw event data. Cloud lists scheduled raw event exports under Enterprise; self-hosted gives you a ClickHouse client and a `SELECT`.- You want retention longer than five years, or no pageview meter at all.If none of those apply, pay the $9. That decision is closer to the one I wrote about in [who pays for WordPress plugin subscriptions on client sites](https://adityaarsharma.com/managing-wordpress-plugin-subscriptions-for-clients/) than it is to a technical argument. I put the whole ledger, including backups and the hours, in [what self-hosting actually costs against the SaaS you are replacing](https://adityaarsharma.com/self-hosting-vs-saas-real-cost/). ## The tag drift I found while writing this One more thing that fell out of the check. My compose file pins `ghcr.io/plausible/community-edition:v3.2.1`. The running container reports something else: `docker inspect -f '{{.Config.Image}}' aditya-analytics-plausible-1` returned `ghcr.io/plausible/community-edition:latest`, and `docker image inspect --format 'created={{.Created}}'` dated that image 2026-05-15T09:20:21Z. ### A pinned tag you never applied is not a pin The file and the running process disagree. Editing a tag in `compose.yml` changes nothing on its own; the container keeps the image reference it was created with until `docker compose up -d` recreates it. So the pin is aspirational until you run the command, and the layer currently serving my analytics was built in May. If you self-host anything, that two-line check is worth putting in a cron. A pinned tag you never applied looks exactly like a pinned tag you did. If you run this across client sites rather than one of your own, that check belongs in the same routine as everything else in [managing WordPress sites for clients](https://adityaarsharma.com/how-to-manage-wordpress-websites-for-clients/). ## The fix, which I have not run yet Adding the config is three steps. Truncating what has already accumulated is a write against a live database, so I am writing it down before I run it rather than after: `# 1. mount logs.xml into the clickhouse service in compose.yml volumes: - ./clickhouse/logs.xml:/etc/clickhouse-server/config.d/logs.xml:ro # 2. recreate the container so the new config is read docker compose up -d --force-recreate plausible_events_db`Step 3 is the destructive one: `TRUNCATE TABLE system.asynchronous_metric_log` and its siblings, against a live database. Copy the four files from the community-edition repository before step 1. Writing the destructive step down before running it is the same reason I put guardrails in front of the agent when [running Claude Code against production WordPress](https://adityaarsharma.com/running-claude-code-against-wordpress-the-complete-setup/). A command you have read is a different risk from a command you have not. ### What I have not run yet The 8.6 GB at the top of this post is the before state, measured today. I have not run step 3 on this instance, so I am not going to tell you what the after looks like. Step 1 and 2 are safe and reversible; step 3 destroys server history you might want if you are actively debugging a query. A related habit that applies to any container you run for years: check what your stack is writing, not only what it is serving. I make the same argument about log files in [the post about a scanner that reported a site clean while it served spam to Googlebot](https://adityaarsharma.com/the-scanner-said-clean-the-site-was-serving-spam-to-googlebot/). The dashboard is not the system. ## Resources - [plausible/community-edition](https://github.com/plausible/community-edition), the official compose repository. Clone the tag, not the branch.- [The Community Edition wiki](https://github.com/plausible/community-edition/wiki), which carries the reverse-proxy and configuration pages the README links to.- [clickhouse/logs.xml at v3.2.1](https://raw.githubusercontent.com/plausible/community-edition/v3.2.1/clickhouse/logs.xml), the 828 bytes this whole post is about.- [ClickHouse operations tips](https://clickhouse.com/docs/en/operations/tips), which the project cites for its low-resource settings.- [DigitalOcean Droplets pricing](https://www.digitalocean.com/pricing/droplets), server-rendered, so you can read it without a browser.- [Plausible Cloud pricing](https://plausible.io/), for the comparison you should actually run. ## One thing to do now Open a shell on your own ClickHouse container and run the `system.parts` query from the top of this post. It takes ten seconds and needs no downtime. If `system.asynchronous_metric_log` outranks your `plausible` tables, you are carrying the same several gigabytes I am, and you now know which 828-byte file you forgot to mount. --- # Theme.json: the parts that actually control your output Source: https://adityaarsharma.com/theme-json-controls-your-output/ Two theme.json files, identical except that one has a `version` key. Here is what WordPress 7.1 does with each, run through `WP_Theme_JSON_Schema::migrate()` on a live install: `WITH version: {"version":3,"settings":{"color":{"palette":[{"slug":"brand","color":"#ee6719","name":"Brand"}]}}} WITHOUT version: {"version":3}`Not a warning. Not a partial merge. The entire file is replaced by a stub. Here is the code that does it, in `wp-includes/class-wp-theme-json-schema.php`: `public static function migrate( $theme_json, $origin = 'theme' ) { if ( ! isset( $theme_json['version'] ) ) { $theme_json = array( 'version' => WP_Theme_JSON::LATEST_SCHEMA, ); } ... }`That is called from the `WP_Theme_JSON` constructor, so it runs on every layer, every request. If your theme.json is being ignored and you cannot see why, check that key first. It is the cheapest possible explanation and it is a one-line fix. This post is about the parts of theme.json that decide what actually reaches the browser: the four layers and their order, what settings and styles each do, the specificity of the CSS core generates, and the three ways your changes get silently dropped. Everything is read from WordPress 7.1 source and checked on a live install, on 2 September 2026. ## Four layers, merged in a fixed order There is no ambiguity here and it is worth memorising, because most theme.json confusion is a layer confusion. `WP_Theme_JSON_Resolver::get_merged_data()` is short enough to read whole: The resolver builds it in one function. It constructs an empty `WP_Theme_JSON`, merges `get_core_data()`, and returns early if the requested origin is `default`. Then it merges `get_block_data()`, then `get_theme_data()`, then `get_user_data()`, returning early at each named origin. | Layer | Origin key | Where it comes from | Beats | | ----- | ---------- | ------------------- | ----- | | Core | `default` | `wp-includes/theme.json` | nothing | | Blocks | `blocks` | each block type's `__experimentalStyle` support | core | | Theme | `theme` | your theme's `theme.json`, plus the parent theme's | core and blocks | | User | `custom` | a `wp_global_styles` post, written by the Site Editor | everything | ### The constant that defines the order The constant is `WP_Theme_JSON::VALID_ORIGINS` and the order in it is the merge order. `LATEST_SCHEMA` is 3. Note that the user layer is called `custom` in the class and `user` in the resolver's cache array, which is a small trap when you are grepping. ### One filter per layer Each layer has a filter: `wp_theme_json_data_default`, `wp_theme_json_data_blocks`, `wp_theme_json_data_theme` and `wp_theme_json_data_user`. A plugin can rewrite any of them. If a value in your output does not appear in any theme.json on disk, one of those four filters is where it came from. ## settings generate variables and gate UI. styles generate rules. ### What settings does, and what styles does The split is real and people mix it up constantly. Anything under `settings` does two things: it emits CSS custom properties, and it turns editor controls on or off. Anything under `styles` emits actual declarations. On a real Twenty Twenty-Five page in WordPress 7.1 the settings half is a `:root` block of custom properties: `--wp--preset--color--base: #FFFFFF`, `--wp--preset--color--contrast: #111111`, `--wp--preset--color--accent-1: #FFEE58`, `--wp--preset--aspect-ratio--16-9: 16/9`, and so on for every preset the theme declares. And the styles half emits one bare `body` rule that consumes them: `background-color`, `color`, `font-family`, `font-size`, `font-weight`, `letter-spacing` and `line-height`, plus the root padding variables. ### How big the inline block actually is The whole `global-styles-inline-css` block on that page is 20,528 bytes, and it is inline rather than a file, so it is on every page and it is not cacheable independently of the HTML. Inside it: 49 rules using the `:root :where(` prefix, 87 `!important` declarations, exactly one bare `body{` rule and one `:root{` rule. Those counts came off that one page with that one theme. The shape is what generalises, and the shape is what the rest of this section is about. ### The gating half nobody expects ![20,528 bytes of inline global styles CSS on one page](https://adityaarsharma.com/wp-content/uploads/2026/09/33e774d6-d2c0-4f5c-a91a-3df80827e8ae_2400x2400.png)Measured on WordPress 7.1 with Twenty Twenty-Five, 2 September 2026.The gating half of settings is the part people discover by accident. Set `settings.color.custom` to false and the custom colour picker disappears from the editor. Set `settings.typography.customFontSize` to false and the numeric font size input goes. That is the same class of problem as [missing colour and underline options in the WordPress editor](https://adityaarsharma.com/fix-missing-color-and-underline-font-options-in-wordpress/), which usually turns out to be a theme.json setting rather than a bug. ## The specificity rules core actually uses This is the piece that explains why your stylesheet sometimes wins and sometimes does not. Core does not emit one selector shape, it emits three, and the choice is made in `WP_Theme_JSON::get_styles_for_block()`: The branch is one assignment: `$general_selector = $element_only_selector ? $selector : ":root :where($selector)"`. An element-only selector stays bare. Everything else gets wrapped in `:root :where(...)`. | What core is styling | Selector emitted | Specificity | | -------------------- | ---------------- | ----------- | | The root (`ROOT_BLOCK_SELECTOR` is `body`) | `body` | 0-0-1 | | An element with an element-only selector, such as `h1` | `h1` | 0-0-1 | | Everything else: blocks, pseudo states, variations | `:root :where(.wp-block-x)` | 0-1-0 | | Preset classes such as `.has-accent-1-color` | class plus `!important` | 0-1-0 plus important | ### Why the root selector is left bare The comment above that code says why the root is left bare: to keep specificity at 0-0-1 for backwards compatibility. `:where()` contributes nothing, so `:root :where(...)` is exactly the specificity of `:root` alone, which is one class. That is the design goal, stated in the source: cap everything block-related at a single class so a theme's own stylesheet can override it without an arms race. It also means your override needs one class and later source order, or two classes. A bare element selector will lose to `:root :where()`. This is the mechanism behind most of the [stubborn spacing you cannot remove](https://adityaarsharma.com/how-to-remove-top-and-bottom-white-space-on-wordpress/) on block themes. ### The preset classes carry !important The preset classes are the exception and they are brutal. `compute_preset_classes()` appends a ruleset whose value is `'var(' . $css_var . ') !important'`. The `!important` is written into the generated CSS by core, not by your theme. So `.has-accent-1-color { color: var(--wp--preset--color--accent-1) !important; }`. If a user has picked a palette colour on a block, you are not overriding it from a stylesheet without your own `!important`. That is deliberate: the user's explicit choice is meant to win. ### What changed in 7.1 One thing changed in 7.1 that is worth knowing if you support older versions. The docblock on `compute_preset_classes()` now reads `@since 7.1.0 Wraps block-level preset classes in :where() to match root-level specificity`, and the code comment explains that without it, block-level palette rules such as `p.has-x-color` outranked equally important rules targeting the same property at 0-1-0. If a palette colour started behaving differently after a 7.1 update, that is the change. ## The user layer, and how it silently disappears The Site Editor writes user global styles into a post of type `wp_global_styles`. That post is tied to a theme by a term in the `wp_theme` taxonomy, and if the term is missing the post is orphaned and its styles never apply. ![The WordPress Site Editor Styles panel showing the typography, colours, background, shadows and layout sections and a revision count.](https://adityaarsharma.com/wp-content/uploads/2026/09/f74c142d-4daa-45a6-8aff-f6dbc313d2b6_3000x1900-scaled.png)The Site Editor Styles panel on WordPress 7.2-alpha-63436 under wp-env, showing the user layer after a global styles write. Screenshot taken 2 September 2026. ### The term that was never set I hit this while testing. I wrote a background and text colour into the global styles post, confirmed the content was saved, flushed the object cache, and the front end still showed the theme's colours. The Site Editor writes through `/wp-json/wp/v2/global-styles/` and behaves the same way: The probe printed post id 6, status publish, type `wp_global_styles`, content carrying my `#0b0e13` background and `#edeff3` text, stylesheet `twentytwentyfive`, and then the two fields that mattered: `terms=` empty, and `user raw={"version":3}`. `terms=` empty. The post existed with the right content, and `WP_Theme_JSON_Resolver::get_user_data()` returned nothing but a version number, because `get_user_data_from_wp_global_styles()` looks the post up through the `wp_theme` term for the current stylesheet. Setting the term fixed it immediately. `wp eval 'wp_set_post_terms( 6, array( "twentytwentyfive" ), "wp_theme" );'` followed by `wp cache flush`, and the body rule went from `background-color: var(--wp--preset--color--base)` to the literal `#0b0e13` I had saved. ### Why a theme switch appears to reset your styles That is also why switching themes appears to reset your Site Editor customisations and switching back restores them. Each theme has its own `wp_global_styles` post, keyed by that term. Nothing is lost, it is scoped. The same scoping is why a child theme gets its own user styles rather than inheriting the parent's, which is a different answer from the one you get for template files and a common source of confusion when [deciding whether a child theme is the right tool](https://adityaarsharma.com/child-theme-vs-normal-wordpress-theme/). ## Version migration, and what v3 changed `migrate()` falls through deliberately, so a v1 file goes to v2 and then straight on to v3 in the same call. The v1 to v2 step renames four settings paths: `border.customRadius` to `border.radius`, `spacing.customMargin` to `spacing.margin`, `spacing.customPadding` to `spacing.padding`, `typography.customLineHeight` to `typography.lineHeight`. ### What v2 to v3 actually changes The v2 to v3 step is the one that changes behaviour rather than names: - If your v2 file defines `settings.typography.fontSizes`, migration sets `defaultFontSizes` to false. In v2, providing font sizes replaced core's. In v3 they are added to core's unless you opt out, so a v3 file that only lists three sizes gets core's defaults alongside them.- The same applies to `settings.spacing.spacingSizes` and `spacingScale`, which set `defaultSpacingSizes` to false on migration.- In v3, `spacingSizes` merges with the generated `spacingScale` sizes instead of replacing them. The source comment is unusually candid about this: it says the v3 behaviour is what was documented for v2 all along and the code never did it, so rather than change v2 behaviour two years later they treat the fix as a v3 breaking change. Migration therefore unsets `spacingScale` for v2 files to preserve the old bug.The practical consequence when you bump a theme from v2 to v3 by hand: add `defaultFontSizes: false` and `defaultSpacingSizes: false` yourself if you were relying on your presets replacing core's, because the automatic migration that was doing that for you stops running the moment you write `"version": 3`. ![The theme.json Version 3 Reference page in the WordPress block editor handbook, showing the settings and styles trees.](https://adityaarsharma.com/wp-content/uploads/2026/09/0732b91d-3115-4615-831d-875ea4f1ca11_2800x2000-scaled.png)developer.wordpress.org/block-editor/reference-guides/theme-json-reference/theme-json-living/, screenshot taken 2 September 2026. ## How to debug it when it does not apply In order, cheapest first. Every one of these is a real cause I hit or read in source today. - **No `version` key.** The whole file is discarded and replaced with `{"version":3}`. Add `"version": 3` and the `$schema` line while you are there.- **The stylesheet is cached.** `wp_get_global_stylesheet()` caches its output and only skips the cache when `wp_is_development_mode( 'theme' )` is true. Put `define( 'WP_DEVELOPMENT_MODE', 'theme' );` in `wp-config.php` while you work, and take it out afterwards.- **A user global styles post is overriding you.** It is the last layer merged and it always wins. `wp post list --post_type=wp_global_styles`, read the content, and remember it is scoped to the theme by a `wp_theme` term.- **Your selector is losing to `:root :where()`.** That is 0-1-0. One element selector will not beat it. Two classes, or one class later in source order, will.- **It is a preset class with `!important`.** Nothing without `!important` overrides those. Change the preset value in theme.json rather than fighting the class.- **A plugin filtered a layer.** Grep your plugins for `wp_theme_json_data_theme` and its three siblings.- **The block does not support that style.** The blocks layer merges block-level defaults, and a block that does not declare a support will not produce the declaration however you write it. ### Two functions to keep in your fingers Two functions worth having in your fingers while debugging: `wp_get_global_settings( array( 'color', 'palette' ) )` and `wp_get_global_styles()`. Both take a path array and both give you the merged result rather than any single file, which is the value you are actually trying to reason about. ## What I would not claim The 20,528 byte figure for global styles is one page, one theme, one version. Twenty Twenty-Five is a large theme.json and a minimal one will produce far less. I have not benchmarked global styles output across a set of themes and I am not going to imply a range from a single sample. What is version-independent is the mechanism: the four layers, the merge order, the three selector shapes and the `!important` on preset classes. Those are in the source and they are what you can reason from. ## One thing to do in the next ten minutes Open your theme's `theme.json` and check the top three lines. You want a `$schema` line and a `version` line, in that order: `{ "$schema": "https://schemas.wp.org/wp/6.7/theme.json", "version": 3, "settings": { ... } }`![The JSON schema for WordPress block theme global settings and styles at schemas.wp.org](https://adityaarsharma.com/wp-content/uploads/2026/09/754ec774-87a4-412f-880a-c966cf62c043_2880x1800-scaled.png)schemas.wp.org/trunk/theme.json, the schema the $schema line points at. Screenshot taken 3 September 2026.Twenty Twenty-Five ships exactly that, pinned to the 6.7 schema rather than `trunk`, which is the right call: pin to the lowest WordPress version you support so your editor validates against what your users are running. If the `version` line is missing, everything below it in that file has been doing nothing, and you are about to get a very satisfying ten minutes. For where theme.json sits in the wider set of changes this cycle, I wrote that up in [WordPress 7.0 is the biggest thing to happen since Gutenberg](https://adityaarsharma.com/wordpress-7-0-is-the-biggest-thing-to-happen-since-gutenberg-and-nobodys-talking-about-it/). If you are working on the block side rather than the theme side, [the block.json asset loading piece](https://adityaarsharma.com/block-json-asset-loading/) covers the other half of what ends up in your head tag. ## Resources - [Theme.json Version 3 Reference](https://developer.wordpress.org/block-editor/reference-guides/theme-json-reference/theme-json-living/), the living specification, generated from the JSON schema.- [schemas.wp.org/trunk/theme.json](https://schemas.wp.org/trunk/theme.json), the schema itself. Per-version copies live at `https://schemas.wp.org/wp/{version}/theme.json`.- [Migrating theme.json to newer versions](https://developer.wordpress.org/block-editor/reference-guides/theme-json-reference/theme-json-migrations/), the official notes on the v1, v2 and v3 differences.- [WP_Theme_JSON_Resolver::get_merged_data()](https://developer.wordpress.org/reference/classes/wp_theme_json_resolver/get_merged_data/) in the code reference, the function that defines the merge order.- [wp-includes/class-wp-theme-json.php in wordpress-develop](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/class-wp-theme-json.php), where the selector and specificity decisions are made.- [Builder basics: demystifying theme.json and global styles](https://wordpress.tv/2022/12/09/builder-basics-demystifying-theme-json-and-global-styles/) on WordPress.tv, with Nick Diego, 9 December 2022. It is the clearest walkthrough of the layer model I have found, and it predates the v3 schema, so check every field name against the current reference before you copy it. --- # WordPress Capabilities and Roles, Read From the Source Source: https://adityaarsharma.com/wordpress-capabilities-and-roles-mapped/ There is no row in your database that says a user can `edit_post`. There is no row that says they cannot. The capability exists only as a string passed into a switch statement, and it is resolved fresh on every check against a specific post ID. ![Administrator holds 61 of them.](https://adityaarsharma.com/wp-content/uploads/2026/09/6a67a6ef-5210-4393-af89-8038cf211839_2912x1632-scaled.png)That is the single fact that explains most of the confusion about WordPress permissions, and it is worth reading the code that produces it. Everything below is from WordPress 7.1, downloaded from wordpress.org on 3 September 2026. `api.wordpress.org/core/version-check/1.7/` reports 7.1 as the current release. ## What each default role is actually granted The roles are built once, at install, by `populate_roles()` in `wp-admin/includes/schema.php` line 719. It calls eight versioned functions in order, each adding what a given WordPress release introduced: `populate_roles_160()`, `_210()`, `_230()`, `_250()`, `_260()`, `_270()`, `_280()` and `_300()`. Nothing has been added since `populate_roles_300()`, which corresponds to WordPress 3.0. Every capability a default role holds in 7.1 was decided by 2010. ### The real per-role totals I replayed every `add_cap()` call across those eight functions to get the real per-role totals. Counting the deprecated `level_0` through `level_10` caps, which are still granted: - **administrator**: 61 capabilities, of which 50 are named and 11 are levels- **editor**: 34, of which 26 are named- **author**: 10, of which 7 are named- **contributor**: 5, of which 3 are named- **subscriber**: 2, of which 1 is named![Named capabilities per default WordPress role](https://adityaarsharma.com/wp-content/uploads/2026/09/8833a61c-1fe5-4d62-9ea3-0e8acc30bab4_2912x1632-scaled.png)Replayed from the eight populate_roles_*() functions in wp-admin/includes/schema.php.Subscriber holds exactly `read` and `level_0`. Contributor adds `edit_posts` and `delete_posts`. That is the entire difference between the two roles: two strings. ### Author, the one that matters for automation Author is where it gets interesting for anything automated. The full named set is `upload_files`, `edit_posts`, `edit_published_posts`, `publish_posts`, `delete_posts`, `delete_published_posts`, `read`. Seven strings, and one of them is `upload_files`, which is the only file-writing capability below editor. Editor gains the `_pages` and `_others` and `_private` variants, plus `moderate_comments`, `manage_categories`, `manage_links` and `unfiltered_html`. It has no capability that touches plugins, themes, users, options or the dashboard. ### The gap between editor and administrator Administrator adds all of those at once: `activate_plugins`, `install_plugins`, `update_plugins`, `delete_plugins`, `edit_plugins`, the same five for themes, `edit_files`, `manage_options`, `edit_users`, `create_users`, `delete_users`, `promote_users`, `list_users`, `remove_users`, `update_core`, `import`, `export`, `edit_dashboard`, `unfiltered_upload` and `switch_themes`. The jump from editor to administrator is 27 named capabilities, and there is nothing in between. That gap is the whole problem with role design in WordPress: the moment someone needs one administrator capability, the standard answer hands them all 27. ![27 named capabilities separate editor from administrator](https://adityaarsharma.com/wp-content/uploads/2026/09/fbcec888-9521-4567-a228-357c22bfd2a2_2400x2400.png)Counted from populate_roles_*() in wp-admin/includes/schema.php. ## The roles live in one option, and it is autoloaded The last thing `populate_roles()` does is `update_option( $wp_roles->role_key, $wp_roles->roles, true )`, inside an `if ( $original_use_db )` guard. `$role_key` is `{prefix}user_roles`. That boolean `true` means the entire role and capability structure for the site is one serialized array, loaded on every request. On a site with a membership plugin that has generated dozens of roles, it is often one of the larger autoloaded rows, and it bypasses the size guard that WordPress 6.6 added, for reasons I worked through in [the autoload audit](https://adityaarsharma.com/wordpress-options-table-autoload-audit/). ### Per-user capabilities live somewhere else Per-user capabilities are separate. They are user meta under `{prefix}capabilities`, and `WP_User` merges the role's caps with the user's own into `$this->allcaps`. A capability granted directly to a user is invisible in any role editor that reads the roles option. ## Why edit_post is not stored anywhere `map_meta_cap()` lives in `wp-includes/capabilities.php` line 45. Its whole job is turning a question about an object into a list of strings to look up in `allcaps`. The code reference states it plainly: this function does not check whether the user has the required capabilities, it just returns what the required capabilities are. Here is the core of the `edit_post` branch, once the post has been fetched and the post type resolved: `if ( $post->post_author && $user_id === (int) $post->post_author ) { if ( in_array( $post->post_status, array( 'publish', 'future' ), true ) ) { $caps[] = $post_type->cap->edit_published_posts; } elseif ( 'trash' === $post->post_status ) { // see below } else { $caps[] = $post_type->cap->edit_posts; } } else { $caps[] = $post_type->cap->edit_others_posts; }`The trash branch reads `_wp_trash_meta_status` from post meta and applies the same test to it. ### What this means for a contributor ![WordPress Developer Resources page for map_meta_cap()](https://adityaarsharma.com/wp-content/uploads/2026/09/36ad8870-36a6-47bf-8826-a198b1dcda0a_2880x1800-scaled.png)developer.wordpress.org, map_meta_cap(). Screenshot taken 3 September 2026.Read what that means for a contributor. A contributor holds `edit_posts` but not `edit_published_posts`. On their own draft, `edit_post` maps to `edit_posts` and they pass. The moment that draft is published, the same check on the same post by the same user maps to `edit_published_posts` and they fail. Nothing about the user changed. The post status changed, and the mapping is computed from the post. ### The trash branch almost nobody knows The trash branch is the detail almost nobody knows: a trashed post is evaluated against the status it had *before* it was trashed, read out of the `_wp_trash_meta_status` post meta. Trashing a published post does not hand editing rights back to its author. The other half of the function is worth knowing about too. If you call `current_user_can( 'edit_post' )` with no post ID, WordPress does not guess. Since 6.1 it fires `_doing_it_wrong()` and then appends `do_not_allow`, which fails closed. Same for `delete_post`. If your plugin has a capability check that mysteriously always fails, look for a missing second argument. ## do_not_allow is a name, not a flag `do_not_allow` appears more than twenty times in `capabilities.php`. It is not special-cased in `map_meta_cap()` at all. The trick is at the end of `WP_User::has_cap()` in `wp-includes/class-wp-user.php`, and the order is the point. `$capabilities = apply_filters( 'user_has_cap', ... )` runs first. Then core sets `$capabilities['exist'] = true`, and then it calls `unset( $capabilities['do_not_allow'] )`. The comments in core say it better than I can: everyone is allowed to exist, and nobody is allowed to do things they are not allowed to do. The function then requires ALL requested caps. ### Why the filter cannot grant it The `unset()` runs after the `user_has_cap` filter. So a plugin that tries to grant `do_not_allow` through that filter, deliberately or by iterating over every capability string it can find, achieves nothing. And because the final check requires *all* returned caps, a single `do_not_allow` in the array denies the whole check regardless of what else the user holds. That is why it is used as the deny token everywhere. One consequence for multisite: `has_cap()` short-circuits for a super admin near the top, returning true for everything *unless* `do_not_allow` is in the mapped caps. A super admin is not "a user with every capability". They are a user for whom the capability lookup is skipped entirely. ## Three capabilities that constants can take away `map_meta_cap()` reads wp-config constants directly, which means a role can hold a capability and still be denied it. Three cases, all in `capabilities.php`: The `unfiltered_html` case appends `do_not_allow` when `DISALLOW_UNFILTERED_HTML` is defined and true. Its comment says so plainly: disallow it for all users, even admins and super admins. The second branch is the multisite one. On multisite, anyone who is not a super admin gets `do_not_allow` as well. ### The multisite branch Note the multisite branch. Editors and administrators are both granted `unfiltered_html` by `populate_roles_160()`, and on multisite neither of them gets it. The role data is unchanged; the mapping denies it. The file editors are next, and they check two separate things: `edit_files`, `edit_plugins` and `edit_themes` share one case. It appends `do_not_allow` if `DISALLOW_FILE_EDIT` is defined and true, or if `wp_is_file_mod_allowed( 'capability_edit_themes' )` returns false. And the install and update group, which is gated only on `DISALLOW_FILE_MODS` through `wp_is_file_mod_allowed()`: `update_plugins`, `delete_plugins`, `install_plugins`, `upload_plugins`, `update_themes`, `delete_themes`, `install_themes`, `upload_themes`, `update_core`. ### What is missing from that list Read that list carefully for what is missing. `activate_plugins` is not in it. It has its own case a few lines below with no file-mod check at all. So `DISALLOW_FILE_MODS` stops a user installing a plugin and stops them updating one, and does not stop them activating a plugin that is already sitting in the directory. If your threat model includes an attacker who can write to `wp-content/plugins` by some other route, that constant does not close the loop. I go through the rest of those constants in [the wp-config walkthrough](https://adityaarsharma.com/wp-config-constants-that-change-behaviour/). ## Building a least-privilege role Here is the practical payoff. Because roles are just an array of capability strings, and because the meta caps resolve to primitives at check time, you can build a role that does one job and nothing else. This is what I would give an automation that publishes posts and touches nothing else: `add_role( 'content_publisher_v1', 'Content Publisher', array( 'read' => true, 'edit_posts' => true, 'edit_published_posts' => true, 'publish_posts' => true, 'upload_files' => true, ) );`Run it once. `add_role()` is a no-op if the role already exists, so bump the slug when you change the set. Deliberately absent: `edit_others_posts`, `delete_posts`, `delete_published_posts`, `manage_categories`, `unfiltered_html` and `moderate_comments`. ### What the role can and cannot do That role can create, publish and revise its own posts and upload media. It cannot touch anyone else's content, cannot delete anything, and cannot create terms. Compared against author, which is the closest built-in, it drops both delete capabilities. Compared against editor it drops 21 named capabilities. Check it before you trust it, against a real post, as the real user: `# meta caps must be checked against an object, never bare wp eval --user=42 'var_dump( current_user_can( "edit_post", 1234 ) );' wp eval --user=42 'var_dump( map_meta_cap( "edit_post", 42, 1234 ) );'`Before those two, `wp role list` confirms the role exists and `wp eval 'var_dump( array_keys( array_filter( get_role("content_publisher_v1")->capabilities ) ) );'` prints exactly what it holds. That second `wp eval` is the one to keep. It shows you the primitive capabilities the check will actually look up, which is the difference between debugging permissions and guessing at them. ## What does not work ### Removing level_* capabilities **Removing `level_*` capabilities to tighten a role.** They are deprecated and mostly vestigial, but `WP_User::has_cap()` still translates a numeric argument through `translate_level_to_cap()`, and some old plugins still check levels. Stripping them buys you nothing measurable and can break code you did not write. ### Editing a role and expecting a reset **Editing a role and expecting it to reset.** `populate_roles()` runs at install. It does not run on update. A role you modified stays modified through every core upgrade, which is convenient until you inherit a site where somebody granted `manage_options` to editor four years ago and nobody remembers. **Assuming a custom post type follows these rules.** The mapping only happens when `$post_type->map_meta_cap` is true, which is *not* the default for `register_post_type()`. When it is false, `map_meta_cap()` returns the post type's raw cap and stops. A CPT registered without `'map_meta_cap' => true` gives you none of the ownership logic above. ### A role is not an authentication boundary **Treating a restricted role as an authentication boundary.** A capability set limits what the account can do through WordPress. It does nothing about how the credential is transported or how long it stays valid. Application passwords in particular map to the full user, with a filterable but wide-open default scope, which I picked apart in [the application passwords security model](https://adityaarsharma.com/wordpress-application-passwords-security-model/). ## If an agent is going to use this role A narrow role is the cheapest guardrail available, because it is enforced by core on every request rather than by your prompt. The rest of the guardrails, including what to do about the capabilities you cannot remove, are in [the piece on running an agent against production WordPress](https://adityaarsharma.com/guardrails-agent-production-wordpress/), and the surface it will reach through is mapped in [the REST API as an agent surface](https://adityaarsharma.com/wordpress-rest-api-agent-surface/). One thing to carry over from the code above: the REST API calls the same `map_meta_cap()`. There is no separate permission system for REST. If your role is correct in wp-admin it is correct over HTTP, and if it is wrong, it is wrong in both. ## The one thing to do Run `wp eval 'var_dump( array_keys( array_filter( get_role("editor")->capabilities ) ) );'` on your oldest site and compare the output against the 34 above. Anything extra was added by a plugin or a person, and on a site that has been running for years there is usually something in that list that nobody would grant today. ## Resources - [map_meta_cap()](https://developer.wordpress.org/reference/functions/map_meta_cap/) in the code reference- [Roles and Capabilities](https://wordpress.org/documentation/article/roles-and-capabilities/) in the WordPress documentation- [wp-admin/includes/schema.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-admin/includes/schema.php), where populate_roles() lives- [wp-includes/capabilities.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/capabilities.php) on wordpress-develop- [wp role](https://developer.wordpress.org/cli/commands/role/) and [wp cap](https://developer.wordpress.org/cli/commands/cap/) in the WP-CLI command reference --- # WooCommerce Performance: The Queries That Actually Cost You Source: https://adityaarsharma.com/woocommerce-performance-queries-that-cost-you/ This is the query WooCommerce runs when you type a word into the product search box in wp-admin. It is in `includes/data-stores/class-wc-product-data-store-cpt.php` on trunk, read 2 September 2026. `SELECT DISTINCT posts.ID, posts.post_parent FROM wp_posts posts LEFT JOIN wp_wc_product_meta_lookup wc_product_meta_lookup ... WHERE posts.post_type IN ('product','product_variation') AND ( posts.post_title LIKE '%blue%' OR posts.post_excerpt LIKE '%blue%' OR posts.post_content LIKE '%blue%' OR wc_product_meta_lookup.sku LIKE '%blue%' OR wc_product_meta_lookup.global_unique_id LIKE '%blue%' )`The elided part is two `LEFT JOIN`s onto `wp_wc_product_meta_lookup`, the second one so a `product_variation` can be matched against its parent's row. Five `LIKE` comparisons per search term, every one of them with a leading wildcard. A leading wildcard makes an index unusable, so MySQL reads every product and every variation row. Then `DISTINCT` forces a temporary table, and `ORDER BY` on two unindexed expressions forces a filesort on top of it. There is no `LIMIT`, because the admin list table calls this with the limit argument unset. It returns every matching id, then feeds the whole list back into a second `WP_Query`. ![Five LIKE comparisons per search term in the wp-admin product search](https://adityaarsharma.com/wp-content/uploads/2026/09/1654bd2b-d435-4db6-aa80-158d03f49472_2400x2400.png)The admin product search query, read from the WooCommerce source.That is the answer to the question people actually have, which is why a WooCommerce admin gets slow on a big catalogue long before the shop does. The shop front runs indexed lookups and sits behind a page cache. The admin runs this, logged in, uncacheable, on every keystroke of a search. Below are the four mechanisms that account for almost all of it, each with the source it comes from and the command to measure it on your own store. No load times, because I did not run a lab and a load time without the host, the theme and the catalogue behind it is decoration. ## Why admin goes first ### Three facts that compound Three structural facts, none of them WooCommerce's fault, all of them compounding. - **The shop front is cacheable and the admin is not.** Every page cache in the world skips logged-in requests. The one page that is guaranteed to hit PHP and MySQL on every load is the one you use all day.- **The front end reads denormalised columns.** WooCommerce maintains `wp_wc_product_meta_lookup` precisely so that catalogue filtering by price, stock and rating does not touch `wp_postmeta`. Admin search does not use it the same way, as the query above shows.- **Orders got a full text index. Products did not.** WooCommerce ships a system status tool called `recreate_order_address_fts_index`, and a database update named `wc_update_940_add_phone_to_order_address_fts_index`. So the team knows leading-wildcard `LIKE` is the problem, and has fixed it for order addresses under High Performance Order Storage. There is no equivalent tool for products in the list I read.If you want a single sentence to hand a client: your customers are reading a cached copy, and you are the only person running the queries. ## Cost one: the autoload set, and the rule that does not apply WordPress loads every option marked autoload on every single request, front end and admin, into one cache entry called `alloptions`. From `wp-includes/option.php` on trunk: The loader is one `SELECT option_name, option_value FROM $wpdb->options WHERE autoload IN (...)`, with no `LIMIT` of any kind. One query, unbounded by size, unserialised into PHP memory, before your theme has done anything. WordPress 6.6 added a guard: The check is `apply_filters( 'wp_max_autoloaded_option_size', 150000, $option )` compared against `strlen( $serialized_value )`. Anything larger returns `false` and is not autoloaded. Here is the part that matters and almost nobody says out loud. That check lives in `wp_determine_option_autoload_value()`, and read the top of that function: if `$autoload` is a boolean it returns immediately, and if it is the string `'on'` or `'yes'` it returns immediately. The 150,000 byte limit only applies when the caller left autoload unspecified. Any plugin that calls `add_option( $key, $value, '', 'yes' )` gets its two megabyte blob autoloaded on every request, on WordPress 6.6 and on 7.1, and the guard never sees it. ### Measure it, do not guess Measure it, do not guess: - `wp option list --autoload=on --format=total_bytes` for the total- `wp option list --autoload=on --fields=option_name,size_bytes --format=csv`, then sort that CSV yourself![The wp option list documentation showing the autoload flag and the size_bytes field.](https://adityaarsharma.com/wp-content/uploads/2026/09/ea2a3331-ea3a-46c8-8a12-6416d655c911_1440x900.png)developer.wordpress.org/cli/commands/option/list, screenshot taken 2 September 2026. Note that --orderby accepts option_id, option_name and option_value only, which is why the command above pipes to sort.The `--orderby` flag does not accept `size_bytes`, which is why every version of this command you find that sorts with `--orderby=size_bytes` is wrong. Pipe it to `sort`. ### How big is too big There is no official threshold. Practically, if `total_bytes` comes back over a megabyte you are unserialising a megabyte on every request including every admin-ajax call, and the fix is to find the top three entries and ask what wrote them. ## Cost two: transients, and the myth about them The common claim is that transients bloat `wp_options` and slow every page load because they are autoloaded. Half right. From `set_transient()` in core: `if ( false === get_option( $transient_option ) ) { $autoload = true; if ( $expiration ) { $autoload = false; } add_option( $transient_option, $value, '', $autoload );`The elided line inside the `if` writes the matching `_transient_timeout_` row, itself never autoloaded. ### Which transients are actually autoloaded A transient with an expiry is **not** autoloaded. A transient with no expiry **is**. So the ones that hurt your request time are the ones somebody wrote with `set_transient( $key, $value )` and no third argument. Everything else is a row count problem, which is a different and much milder problem. WooCommerce's own transients do carry an expiry. Reading `class-wc-product-variable-data-store-cpt.php` on trunk, a variable product writes two: - `wc_product_children_{id}`, set with `DAY_IN_SECONDS * 30`- `wc_var_prices_{id}`, also 30 days, holding a JSON map keyed by a price hashTwo transients plus two timeout rows is four `wp_options` rows per variable product, none autoloaded, all sitting in the table for a month. On a catalogue of a few thousand variable products that is tens of thousands of rows, which is fine for MySQL and annoying for anything that dumps the table. ### The price hash, and the 8 kilobyte cap The interesting failure is the price hash. WooCommerce builds a cache key from the callbacks registered on its price filters, so a plugin that changes prices per user role, per customer, or in real time produces a new hash per visitor. The transient grows one entry at a time until it does not fit anywhere useful. WooCommerce added a cap, and the comment on it is the most concretely useful line I read all day: The comment above it is unusually honest. It says the cap is size based rather than count based because the number of variations per hash varies widely, and that hash churn from real-time or role-based pricing can bloat the value over the 30-day TTL. The value: `wp_using_ext_object_cache() ? 65536 : 8192`. 8 kilobytes without an object cache, because past that InnoDB stores the value off-page and reading one row becomes two reads. 64 kilobytes with one, because that is a Memcached slab. If you run a dynamic pricing plugin on variable products, that cap is now doing constant work for you, and before it existed those rows grew without limit. ![WooCommerce price transient size cap: 65,536 bytes with an object cache, 8,192 without](https://adityaarsharma.com/wp-content/uploads/2026/09/a92a9fd6-db29-41dd-b4d4-74e938fa2b55_2912x1632-scaled.png)From class-wc-product-variable-data-store-cpt.php on trunk. ### Clearing is a tool call, not a DELETE Clearing is a tool call, not a `DELETE`. Both of these are in `class-wc-rest-system-status-tools-v2-controller.php`: - `wp wc tool run clear_expired_transients --user=1` deletes ALL expired transients site-wide- `wp wc tool run clear_transients --user=1` clears the WooCommerce product and shipping transients ## Cost three: the lookup tables, and how to tell yours is stale WooCommerce keeps a denormalised copy of the product fields it filters on. The real table definition is in `includes/class-wc-install.php` on trunk. It stores `product_id`, `sku`, `virtual`, `downloadable`, `min_price`, `max_price`, `onsale`, `stock_quantity`, `stock_status`, `rating_count`, `average_rating`, `total_sales` and `tax_status`, with indexes on the columns your shop filters on. Every filter widget on your shop page maps to one of those indexed columns. Filter by price and you get a range scan on `min_max_price` instead of a join through `wp_postmeta` for `_price`. That is the whole point of the table, and it is why a shop archive stays quick when a catalogue grows. ### How the lookup table drifts It also means the table can drift. Anything that writes product meta directly with `update_post_meta()` instead of through `WC_Product`, which includes most old import scripts and a fair number of plugins, updates `wp_postmeta` and never touches the lookup row. The symptom is specific and recognisable: the product page shows the right price, and the shop filter does not find it. ### Finding the drift Find the drift with one query. It joins `wp_postmeta` on `_price` against `wp_wc_product_meta_lookup.min_price` for published products, and returns any row where the lookup is missing or the two values disagree once the meta value is cast to `DECIMAL(19,4)`. Rows in that result are products your filters cannot see correctly. The fix is one tool, and it is a long-running one on a large catalogue: `wp wc tool run regenerate_product_lookup_tables --user=1`. Same call over REST if you prefer: `PUT /wp-json/wc/v3/system_status/tools/regenerate_product_lookup_tables`. The safety rules for that kind of write are in [what is safe to script against the WooCommerce REST API](https://adityaarsharma.com/woocommerce-rest-api-what-is-safe-to-script/). ## Cost four: write amplification, which nobody budgets for Reads get all the attention. On a busy store the writes are what fall over, and the reason is index maintenance. Every secondary index on a table is another structure the engine updates on every insert. I counted the indexes in the shipped table definitions: | Table | Secondary indexes | Written on | | ----- | ----------------- | ---------- | | `wp_actionscheduler_actions` | 9 | Every scheduled job, and WooCommerce schedules a lot of them | | `wp_wc_orders` | 9 | Every order create and update under HPOS | | `wp_wc_product_meta_lookup` | 7 | Every product save and every stock change | | `wp_wc_orders_meta` | 2 | Every order meta write | ### Action Scheduler grows without anybody deciding Action Scheduler is the one to look at first, because it is the table that grows without anybody deciding it should. From [its schema class](https://github.com/woocommerce/action-scheduler/blob/trunk/classes/schema/ActionScheduler_StoreSchema.php), the actions table carries nine keys including one on `args` and a four column composite on `claim_id, status, priority, scheduled_date_gmt`. And from `ActionScheduler_QueueCleaner.php`: - Default retention is `2678400` seconds, which is 31 days, filterable with `action_scheduler_retention_period`.- Failed actions are kept three times as long, three months, since Action Scheduler 4.0.0.- The cleaner's default batch size is 20. Twenty rows per run.Twenty per run against a table that a busy store adds thousands of rows a day to is a losing race. That is the mechanism behind every "my `wp_actionscheduler_actions` table is nine gigabytes" thread. Check yours: Two queries answer it. `wp db query "SELECT status, COUNT(*) FROM wp_actionscheduler_actions GROUP BY status;"` gives you the backlog by status. Then read `information_schema.TABLES` for `data_length` and `index_length`, converted to megabytes and ordered descending, to see which tables are actually large. ### Reading index size against data size Read the `index_mb` column next to `mb`. When index size approaches or exceeds data size, you are paying more to maintain the lookups than to store the rows, and every insert is carrying that cost. ## The HPOS setting that doubles your order writes High Performance Order Storage moves orders out of `wp_posts` into four purpose-built tables: `wp_wc_orders`, `wp_wc_order_addresses`, `wp_wc_order_operational_data` and `wp_wc_orders_meta`. Order status becomes a `varchar(20)` column with its own index rather than a `post_status` shared with every post type on the site. ### Compatibility mode, and when to turn it off ![WooCommerce documentation page for High Performance Order Storage](https://adityaarsharma.com/wp-content/uploads/2026/09/88c2325b-7aa5-4b0e-939a-5ea409d5cdaf_2880x1800-scaled.png)developer.woocommerce.com, High Performance Order Storage. Screenshot taken 3 September 2026.The setting to check is compatibility mode. [WooCommerce's own CLI documentation](https://github.com/woocommerce/woocommerce/blob/trunk/docs/wc-cli/wc-cli-commands/wc-hpos.md) describes it as the mode that "keeps the HPOS and posts datastores in sync". In sync means every order write happens twice, once into the new tables and once into `wp_posts` and `wp_postmeta`. It exists so an incompatible plugin keeps working, and it is frequently left on years after the plugin that needed it was removed. - `wp wc hpos status`- `wp wc hpos compatibility-info`, which active plugins are actually incompatible- `wp wc hpos count_unmigrated`, orders still pending syncIf `compatibility-info` lists no incompatible active plugins and `count_unmigrated` is zero, compatibility mode is buying you nothing and costing you every order write. Turn it off in a staging copy first, because that is a one-way door for anything still reading the old tables. ## Do this in the next ten minutes Four commands, in this order, on the store that feels slow. `wp option list --autoload=on --format=total_bytes wp db query "SELECT status, COUNT(*) FROM wp_actionscheduler_actions GROUP BY status;" wp wc hpos status wp db query "SELECT table_name, ROUND(((data_length+index_length)/1024/1024),1) mb FROM information_schema.TABLES WHERE table_schema=DATABASE() ORDER BY (data_length+index_length) DESC LIMIT 10;"`Four numbers. They will tell you which of the four mechanisms above you actually have, which is a better use of ten minutes than installing another caching plugin. If you are running these across a fleet rather than one store, [how I manage WordPress sites for clients](https://adityaarsharma.com/how-to-manage-wordpress-websites-for-clients/) and [the site management tools comparison](https://adityaarsharma.com/best-8-wordpress-management-tools/) cover doing it at scale, and [the Claude Code against WordPress setup](https://adityaarsharma.com/running-claude-code-against-wordpress-the-complete-setup/) is how I run this sequence without typing it. ### What I am not claiming Two things I have deliberately not claimed. I have not given you a millisecond figure for any of this, because it depends on your host, your row counts and your object cache, and a number without those is worse than no number. And I have not told you these are your top four costs, only that they are four real mechanisms with a documented source and a command to measure each one. Measure before you fix. A store whose problem is unoptimised images is not helped by any of this, and [bulk-compressing WordPress images](https://adityaarsharma.com/how-to-compress-wordpress-images-in-bulk/) would do more in an afternoon. ### The forty minute version For the long version of the order storage half, this conference talk is the best thing I found. https://www.youtube.com/watch?v=kd66LA1LTq0**[Luc Princen: Scaling up WooCommerce](https://www.youtube.com/watch?v=kd66LA1LTq0)**, on the WordCamp Nederland channel. Forty minutes on high performance order storage: why the posts table stopped working for orders, and what the new schema does about it. Verified via the YouTube oEmbed endpoint on 2 September 2026. One last thing that belongs here and usually gets filed under SEO. Faceted URLs and add-to-cart links generate uncached, uncacheable requests at crawler volume, and every one of them runs the product query stack. Stopping those from being crawled is a performance fix as much as an indexing fix, and the how is in [preventing WooCommerce add-to-cart URLs from being crawled](https://adityaarsharma.com/how-to-prevent-woocommerce-add-to-cart-dynamic-urls-from-crawling/). Where core is heading on all of this is in [what WordPress 7.0 actually changes](https://adityaarsharma.com/wordpress-7-0-is-the-biggest-thing-to-happen-since-gutenberg-and-nobodys-talking-about-it/). ## Resources - [class-wc-product-data-store-cpt.php](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php), the admin product search query- [class-wc-install.php](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/class-wc-install.php), every WooCommerce table definition- [class-wc-product-variable-data-store-cpt.php](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/data-stores/class-wc-product-variable-data-store-cpt.php), the variation price transient and its size cap- [WordPress option.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/option.php), `set_transient()` and `wp_determine_option_autoload_value()`- [Action Scheduler store schema](https://github.com/woocommerce/action-scheduler/blob/trunk/classes/schema/ActionScheduler_StoreSchema.php) and its queue cleaner- [OrdersTableDataStore.php](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Internal/DataStores/Orders/OrdersTableDataStore.php), the HPOS table definitions- [wp option list](https://developer.wordpress.org/cli/commands/option/list/), the `--autoload` flag and `size_bytes` field- [wp wc hpos command reference](https://github.com/woocommerce/woocommerce/blob/trunk/docs/wc-cli/wc-cli-commands/wc-hpos.md) --- # Building a Gutenberg block in 2026: the current, correct way Source: https://adityaarsharma.com/gutenberg-block-2026/ This is the file tree that `@wordpress/create-block` produced for me this morning, on version 4.97.0: The scaffold wrote `code-copy.php`, `readme.txt`, `package.json` and `.wp-env.json` at the root, with the block itself under `src/code-copy/`: `block.json`, `edit.js`, `index.js`, `render.php`, `style.scss`, `editor.scss` and `view.js`. Two things in there will not match the tutorial you are reading. The block source is at `src/code-copy/block.json`, not `src/block.json`, because the scaffold assumes a plugin can hold more than one block. And there is a `build/blocks-manifest.php`, which is not something you write and not something older guides mention at all. The bigger difference is in the plugin bootstrap. Here is the whole registration file the scaffold generated: `function aditya_code_copy_block_init() { wp_register_block_types_from_metadata_collection( __DIR__ . '/build', __DIR__ . '/build/blocks-manifest.php' ); } add_action( 'init', 'aditya_code_copy_block_init' );`No `register_block_type()`. That single function replaces it, and it is the current, correct way to register blocks. Everything below is what I actually ran, on 2 September 2026, against WordPress trunk and 7.1 in Docker. WordPress 7.1 is the current release, confirmed from the version-check endpoint on `api.wordpress.org`. ## The command, and the arguments worth knowing `npx @wordpress/create-block@4.97.0 code-copy \ --namespace aditya \ --title "Code Copy" \ --variant dynamic \ --wp-env` ### What the flags do That runs non-interactively, installs dependencies, and runs a production build before it finishes, which is why `build/` already exists in the tree above. The flags that matter: - `--variant dynamic` gives you a `render.php` and server rendering. The default static variant gives you a `save.js` instead. Pick dynamic unless the block output never depends on anything outside the post content.- `--namespace` becomes the part before the slash in the block name. Get it right at scaffold time, because changing it later invalidates every existing instance of the block in post content.- `--wp-env` writes a `.wp-env.json` and adds `@wordpress/env` as a dev dependency.- `--no-plugin` scaffolds only the block files, for adding a block to a plugin that already exists.- `--template es5` exists and produces a build-step-free block. It is a legacy escape hatch, not a starting point. ### The dist-tag per WordPress version There is a second thing on npm worth knowing. `@wordpress/create-block` publishes a dist-tag per WordPress version, so you can scaffold against the packages that shipped with a specific release: `npm view @wordpress/create-block dist-tags` lists a tag per WordPress version: `wp-6.7` at 4.51.1, `wp-6.8` at 4.62.1, `wp-6.9` and then `latest`. Checked on 2 September 2026. There is no `wp-7.1` tag yet even though 7.1 has shipped, so `latest` is what you get for current work. If you are maintaining a plugin that has to run on 6.7, scaffold with `@wordpress/create-block@wp-6.7` and you get a package set that matches that release rather than one that quietly depends on newer core JavaScript. ## block.json, as generated `{ "$schema": "https://schemas.wp.org/trunk/block.json", "apiVersion": 3, "name": "aditya/code-copy", "editorScript": "file:./index.js", "editorStyle": "file:./index.css", "style": "file:./style-index.css", "render": "file:./render.php", "viewScript": "file:./view.js" }`The keys I cut from that listing are the ones nobody argues about: `version` 0.1.0, `title`, `category` widgets, `icon` smiley, a placeholder `description`, an empty `example`, `supports.html` false and `textdomain`. ### The five asset fields are not interchangeable ![WordPress block editor handbook page for block.json metadata](https://adityaarsharma.com/wp-content/uploads/2026/09/5d1ecbb6-22c9-438d-b52b-b86718db7a1f_2880x1800-scaled.png)developer.wordpress.org, Metadata in block.json. Screenshot taken 3 September 2026.Five asset fields, and they are not interchangeable. `editorScript` and `editorStyle` load in the editor only. `style` loads in both the editor and the front end. `viewScript` loads on the front end only, and only on pages where the block renders. `render` points at the PHP file that produces the front-end markup. The one field that is not there and that you should think twice about is `script`, which loads in both contexts and, as of the last few releases, is the field most likely to put a file on pages that do not contain your block. I measured what each one does in [the piece on block.json asset loading](https://adityaarsharma.com/block-json-asset-loading/). ### Keep the $schema line `$schema` is worth keeping. It is not decoration: it gives you completion and validation in any editor that reads JSON schema, and it will tell you when you have invented a field that does not exist. ## apiVersion 3, and the thing that changed in 2026 Every tutorial tells you to set `apiVersion` to 3 so the block works in the iframed editor. That advice is still right, but the reason changed, and it changed twice. ![The WordPress block editor handbook API Versions page listing the three changes to iframe behaviour across WordPress 6.3, 7.0 and 7.1.](https://adityaarsharma.com/wp-content/uploads/2026/09/8bda5596-1680-41ee-a77e-2f208895b469_2800x1800-scaled.png)developer.wordpress.org/block-editor/reference-guides/block-api/block-api-versions/, screenshot taken 2 September 2026. The page's own last-updated date is 24 July 2026. ### What 6.3 actually required Read the third bullet. WordPress 6.3 iframed the post editor only when every registered block declared version 3. WordPress 7.0 narrowed that check to the blocks actually present in the post content. Gutenberg 23.6 and WordPress 7.1 always iframe the post editor, whatever the blocks declare. ### What apiVersion 3 buys you now The practical consequence is the opposite of what people assume. Setting `apiVersion` to 3 no longer buys you the iframe, because you get the iframe regardless. What it buys you now is honesty: it is a declaration that your block has been tested inside one. A block still on version 1 or 2 that reached into `document`, measured `window` width, or assumed its styles came from the parent page will break on 7.1, and no version number will save it. If you maintain an older block, that is the migration to do this quarter. ## The registration function, and the manifest `wp_register_block_types_from_metadata_collection()` is core as of 6.8.0. It sits on top of `wp_register_block_metadata_collection()`, which is core as of 6.7.0. The generated manifest is a plain PHP array of every `block.json` in the build directory. `blocks-manifest.php` opens with a comment saying it is generated and must not be edited by hand, then returns an array keyed by block directory name, each value the parsed contents of that block's `block.json`. ### Why the collection call is cheaper ![WordPress 6.8.0 added wp_register_block_types_from_metadata_collection()](https://adityaarsharma.com/wp-content/uploads/2026/09/d87d67a9-5ccc-40fc-b49a-c8c24b529ddb_2400x2400.png)The core version that added the registration call the scaffold now generates.The mechanism is the point. `register_block_type_from_metadata()` in `wp-includes/blocks.php` does three filesystem calls per block when no collection is registered: `file_exists()` on the block.json path, `wp_json_file_decode()` to read and parse it, and `realpath()` to normalise the path it stores. When a collection is registered it takes the metadata from `WP_Block_Metadata_Registry` instead and skips all three. For a one-block plugin the saving is noise. For a plugin shipping forty blocks that is a hundred and twenty filesystem calls per request replaced by one `require` of a file the opcode cache already holds. That is derived from reading the function rather than benchmarked, and it is why core did the same thing for its own blocks first, in `wp-includes/blocks/blocks-json.php`. ### The manifest comes from the build The manifest is generated by the build, not by you. It comes from two flags in `package.json`: `wp-scripts build --webpack-copy-php --blocks-manifest`, with the same pair on the `start` script. `--webpack-copy-php` copies `render.php` and `block.json` into `build/`. `--blocks-manifest` writes `blocks-manifest.php`. Drop either flag and the plugin registers nothing, with no PHP error, because the manifest path simply does not exist and the function returns quietly. That is the single most likely reason a freshly cloned block plugin shows no block in the inserter. ## The build, as it actually ran The build emitted `code-copy/index.js` at 1.2 KiB alongside `index.asset.php`, `block.json`, `render.php`, `style-index.css` and `blocks-manifest.php`, and finished without a warning. ### The asset file people delete by mistake `@wordpress/scripts` was 34.2.0 and webpack 5.110.3. The generated `index.asset.php` is the part people delete by accident: It is a single line: `return array('dependencies' => array('react-jsx-runtime', 'wp-block-editor', 'wp-blocks', 'wp-i18n'), 'version' => ...)`. That file is how WordPress knows your editor script needs `wp-blocks` before it runs, and the version hash is how cache busting works. It is derived from your imports by the build. If you hand-write your enqueues instead, you are hand-maintaining that dependency list, and you will get it wrong the first time you add a package. ## What the dynamic variant gives you The generated `render.php` is four useful lines: a paragraph tag, `` inside it, and `esc_html_e()` around the placeholder string. ### What the wrapper function emits `get_block_wrapper_attributes()` is what emits the class names and inline styles that the block supports system generated from the user's settings in the sidebar. Skip it and every alignment, colour and spacing control in the editor will appear to work and then do nothing on the front end. Three variables are in scope in that file: `$attributes`, `$content` and `$block`. Rendered on the front end, that becomes `

Code Copy - hello from a dynamic block!

`. ![The WordPress block editor showing the scaffolded Code Copy block selected in a post, with its editor styles applied.](https://adityaarsharma.com/wp-content/uploads/2026/09/57027484-2d93-4051-bc30-f15c3081045a_3000x1900-scaled.png)The scaffolded block in the editor on WordPress 7.2-alpha-63436 running under wp-env, screenshot taken 2 September 2026. ## The Docker part: what wp-env actually starts `@wordpress/env` was 11.14.0. It is not a bundled runtime, it is a Docker Compose project it writes for you. The images, read out of `lib/runtime/docker/build-docker-compose-config.js` and `docker-config.js`: | Service | Image | Note | | ------- | ----- | ---- | | mysql, tests-mysql | `mariadb:lts` | used as-is | | wordpress, tests-wordpress | built `FROM wordpress` | plus `:php8.3` when you set a PHP version | | cli, tests-cli | built `FROM wordpress:cli` | plus `-php8.3` when you set a PHP version | | phpmyadmin | `phpmyadmin` | only when enabled | ### Ports, and the commands worth memorising Default ports are 8888 for development and 8889 for tests, from `lib/config/parse-config.js`. Commands that work: - `npx wp-env start` boots both environments- `npx wp-env start --auto-port` picks free ports when 8888 is taken- `npx wp-env run cli wp ...` runs WP-CLI inside the container- `npx wp-env destroy` removes the project and its volumes ### Four things that cost me time Four things that cost me time, all real, all in one session: - If another `wp-env` project is already on 8888, `start` dies with `Bind for 0.0.0.0:8889 failed: port is already allocated` after building every image. Use `--auto-port`, or pin `port` and `testsPort` in `.wp-env.json`. `--auto-port` picks a different port on each start, so anything you have bookmarked will break.- `SCRIPT_DEBUG` is true by default in the development environment and false in the tests environment. That means the development site serves unminified core CSS and JS, so any file size you measure there is not the size your users get.- `wp-env run cli wp core download --version=6.9.7` silently drops the `--version` flag, because `wp-env` consumes it as its own version flag. Go around it with `docker exec` straight into the CLI container.- Starting both environments now prints a deprecation warning: `wp-env starts both development and tests environments by default. This behavior is deprecated and will be removed in a future version.` Add `"testsEnvironment": false` if you do not need the second one.There is also an experimental `--runtime playground` flag that runs the site on WordPress Playground instead of Docker. It exists in 11.14.0. I have not put it through anything real, so treat that as untested rather than recommended. ## What I would do differently from the scaffold The generated plugin header says `Requires at least: 6.8`, which is honest, because `wp_register_block_types_from_metadata_collection()` did not exist before 6.8.0. If you need to support older core, you cannot use the generated bootstrap. Either raise the floor or write the registration by hand against `register_block_type( __DIR__ . '/build/code-copy' )` and accept the per-block filesystem reads. ### Pin core, do not clone trunk The generated `.wp-env.json` sets `"core": "WordPress/WordPress"`, which clones the GitHub mirror and gives you trunk. Mine came up as 7.2-alpha-63436. That is the right default for a plugin author who wants early warning, and the wrong default if you are trying to reproduce a customer bug on a specific release. Pin it when you need to. Finally, delete `view.js` and the `viewScript` line if you are not using them. The scaffold ships a `console.log` and its own comment tells you to remove it. It is a 58 byte file plus a request, and shipping it means every page containing your block loads a script that prints a greeting. ## One thing to do in the next ten minutes If you maintain a block plugin, open its `block.json` and check `apiVersion`. If it is 1 or 2, install WordPress 7.1, put the block in a post, and open the editor. The post editor is now always iframed, so whatever the block was relying on from the parent document is gone. Better to find that on your own laptop than in a support ticket. If your block outputs code samples, [the copy code button for Gutenberg code blocks](https://adityaarsharma.com/how-to-add-copy-code-button-to-wordpress-code-blocks/) is the same enqueue problem in miniature, and worth reading first. For context on why so much moved at once in this cycle, I wrote up the release in [WordPress 7.0 is the biggest thing to happen since Gutenberg](https://adityaarsharma.com/wordpress-7-0-is-the-biggest-thing-to-happen-since-gutenberg-and-nobodys-talking-about-it/). If you are building blocks for clients rather than for the directory, [the go to settings link after install](https://adityaarsharma.com/go-to-plugin-settings-link-after-install/) is the small courtesy that saves you the most support email. ## Resources - [Metadata in block.json](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/), the field-by-field reference, including the default of 1 for apiVersion.- [Block API Versions](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-api-versions/), the page that records the 6.3, 7.0 and 7.1 iframe changes.- [Migrating Blocks for iframe Editor Compatibility](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-api-versions/block-migration-for-iframe-editor-compatibility/), the official migration guide.- [@wordpress/create-block on npm](https://www.npmjs.com/package/@wordpress/create-block), 4.97.0 at the time of writing, with the per-release dist-tags.- [@wordpress/env on npm](https://www.npmjs.com/package/@wordpress/env), 11.14.0 at the time of writing.- [More efficient block type registration in 6.8](https://make.wordpress.org/core/2025/03/13/more-efficient-block-type-registration-in-6-8/), the dev note the generated plugin header links to.- [WordPress Developer Hours: Styling Blocks](https://wordpress.tv/2023/07/26/wordpress-developer-hours-styling-blocks-july-2023/) on WordPress.tv, with Michael Burridge, Justin Tadlock and Ryan Welcher, 26 July 2023. It predates the manifest work, so treat the registration parts as history and the styling parts as current. --- # Elementor Performance: What Actually Costs You, Measured Source: https://adityaarsharma.com/elementor-performance-what-actually-costs-you/ Elementor 4.2.4 ships a front-end stylesheet of 54,666 bytes and a front-end script of 32,098 bytes. Gzipped, that is 7,269 and 10,437. On the same page, WordPress 7.1 ships a block stylesheet of 140,761 bytes, 19,113 gzipped, whether or not you used a single block. Byte sizes read from content-length on the published files, 2 September 2026. ![The heaviest stylesheet on an Elementor page is not Elementor](https://adityaarsharma.com/wp-content/uploads/2026/09/5ba1bb47-51f0-4a0e-a7a0-7d473bee9429_2912x1632-scaled.png)So the base cost of running Elementor is not what makes an Elementor page slow. Three things do, and only one of them is a plugin setting. This is what each one actually is, where the number comes from, and how to read it on your own site. One thing I am not going to do here is quote a load time. I did not run a lab, and a page-load number without the host, the theme, the images and the network behind it is decoration. Everything below is either a byte count I measured, a threshold Google published, or a mechanism I read in the source. ## The thresholds, so we are arguing about the same numbers Google's Core Web Vitals set and their published targets, all assessed at the 75th percentile of page loads, segmented across mobile and desktop: | Metric | Good | Poor | Source and date | | ------ | ---- | ---- | --------------- | | Largest Contentful Paint | 2.5s or less | over 4.0s | web.dev/articles/lcp, last updated 4 September 2025 | | Interaction to Next Paint | 200ms or less | over 500ms | web.dev/articles/inp, last updated 2 September 2025 | | Cumulative Layout Shift | 0.1 or less | over 0.25 | web.dev/articles/vitals, last updated 31 October 2024 | Separately, Lighthouse's DOM size audit has its own numbers, and they are the ones that matter for a page builder. From the Chrome documentation, last updated 21 June 2024: it warns when the `body` element has more than about 800 nodes and errors when it has more than about 1,400. It reports three figures, not one: total DOM elements, maximum DOM depth, and maximum child elements. ### Why depth costs you twice Depth is the one page builders push on. A deeply nested tree costs you twice: the browser recomputes position and style down the whole chain on every change, and the memory footprint of the tree itself grows. That is a responsiveness cost, so it lands on Interaction to Next Paint rather than on load time. ## Cost one: DOM depth and node count Elementor has been cutting wrapper elements out of its output since version 3.0, and its own documentation lists them by release: | Elementor version | Wrappers removed | | ----------------- | ---------------- | | 3.0 | `.elementor-inner`, `.elementor-row`, `.elementor-column-wrap` | | 3.2 | `.elementor-image`, `.elementor-text-editor` | | 3.6 | `elementor-section-wrap` | | 3.19 | the changes above became part of core and are no longer optional | Source: Elementor's Optimized DOM Output documentation, last updated 1 February 2024, and the original developer post from 23 June 2020. That last row is the one that breaks sites. If any CSS you wrote selects `.elementor-column-wrap`, it has been dead since 3.19 and no setting brings it back. ### Container against Section and Column The bigger structural lever is Container versus the old Section, Column and Inner Section model. Elementor published a direct comparison of the same visual result built both ways, last updated 18 May 2026: | Build method | Div elements | Lines of HTML | | ------------ | ------------ | ------------- | | Section / Column / Inner Section | 37 | 105 | | Container | 23 | 76 | | Reduction | 14 | 29 | That is their measurement of one section, not mine, and it is a fair one to cite because it is the vendor arguing against its own older feature. Scaled across a landing page with eight sections, that is the difference between clearing the Lighthouse warning threshold and not. The Container feature is marked stable in the source, with `'default' => self::STATE_INACTIVE` but `'default_active' => true` for sites installed on 3.16.0 or later. In other words, a site built in 2023 or later already has it and an older site probably does not. Turning it off later carries this warning in Elementor's own experiment definition: > Container-based content will be hidden from your site and may not be recoverable in all cases. > > Elementor, core/experiments/manager.php, read 3 September 2026![Elementor help page listing experimental and stable performance features including Optimized Control Loading and Element Caching](https://adityaarsharma.com/wp-content/uploads/2026/09/1862e00b-9293-49ef-ab2d-a3c083cf094f_2800x1720-scaled.png)Elementor's own performance features page, read 3 September 2026. The experiments are listed as experimental or stable, per tab. ### The Optimized Markup experiment There is a second lever on the same axis. The `e_optimized_markup` experiment, titled Optimized Markup, is marked stable, defaults to inactive, and defaults to active only for sites installed on 3.30.0 or later. Its description in the source is honest about the price: > Reduce the DOM size by eliminating HTML tags in various elements and widgets. This experiment includes markup changes so it might require updating custom CSS/JS code and cause compatibility issues with third party plugins. > > Elementor, Optimized Markup experiment description, read 3 September 2026Experiments are plain option rows, prefixed `elementor_experiment-`, so you can read the whole set without opening the admin: `wp option list --search='elementor_experiment-*' --format=table` ### Measure your own page in the console And these are the three numbers Lighthouse reports, straight from the console on any page. I ran this to check it returns what it claims before putting it here: `const all = [...document.querySelectorAll('body *')]; const depth = e => { let d = 0; while ((e = e.parentElement)) d++; return d; }; const deepest = all.reduce((a, b) => depth(b) > depth(a) ? b : a); console.log(all.length, depth(deepest), deepest);`Run that in the console on your worst page. It prints the node count, the maximum nesting depth, and the deepest element itself. Expand that last one in the console and you can walk the chain of wrappers upward. That deepest element is the useful output. Walking up from it names the exact chain of wrappers to attack, and on most Elementor pages that chain runs through a nested Inner Section or a widget wrapped in more containers than the design needs. Some of that nesting is also where [the stubborn white space above and below WordPress sections](https://adityaarsharma.com/how-to-remove-top-and-bottom-white-space-on-wordpress/) comes from, so you fix two problems with one pass. ## Cost two: the CSS files Elementor generates per page Elementor does not style your page from its shipped stylesheet. It compiles a stylesheet per document. From `core/files/base.php` and `core/files/css/post.php` in the current source. The constants are in `core/files/base.php`: `UPLOADS_DIR` is `elementor/`, `DEFAULT_FILES_DIR` is `css/`, `FILE_PREFIX` is `post-` and the meta key is `_elementor_css`. So every page you build produces `wp-content/uploads/elementor/css/post-.css`, enqueued with the handle `elementor-post-` and cache-busted with a `?ver=` taken from a timestamp stored in the `_elementor_css` post meta. The site kit is itself a post, so your global colours and typography arrive as another `post-.css` on every page. ### External or internal CSS There is a switch on how that CSS reaches the browser, and both settings have a real cost. The switch is one line in the same class. `use_external_file()` returns true unless `get_option( 'elementor_css_print_method' )` is `internal`. | elementor_css_print_method | What happens | What it costs | | -------------------------- | ------------ | ------------- | | external (default) | A real .css file per post, enqueued normally | One extra render-blocking request per page, and the file has to exist on disk when the request arrives | | internal | The whole blob goes into `_elementor_css` meta and prints as ` | **kept verbatim** | | | **kept verbatim** | | | **removed** | | | **removed** | | , , | kept | | | href removed | | font-family="Datalegreya" | attribute removed | | class="data-table" | attribute removed | | id="documentation" | attribute removed | | , <desc>, role, aria-label | kept | Script execution is genuinely closed. The rest of that table is the part nobody writes about. ### Two root causes, both in a couple of lines The element check is one line: `if ( ! in_array( strtolower( $element->tagName ), $allowed_tags ) )`. `$tag_name = $element->tagName; if ( ! in_array( strtolower( $tag_name ), $allowed_tags ) ) {`![No fe star filter primitive survives the sanitizer.](https://adityaarsharma.com/wp-content/uploads/2026/09/cce05e7d-ff9e-4b7c-9ad9-e0fafaa08328_2400x2400.png)Built from the sanitizer run on PHP 8.5.5 with libxml 2.9.13, 2 September 2026. ### Why every fe* primitive disappears The tag name is lowercased before comparison, and the allow list stores SVG's camel-case element names in their real form: `feGaussianBlur`, `feDropShadow`, `animateMotion`, the whole `fe` family. `strtolower('feGaussianBlur')` is never equal to `'feGaussianBlur'`, so every filter primitive and every SMIL animation element is silently deleted. The names that happen to be stored lowercase in the same list, such as `lineargradient` and `clippath`, survive. That is why gradients work and blurs do not. ### The empty filter left behind Worse than a missing effect: the `<filter>` wrapper stays behind, empty, and the `filter="url(#b)"` reference still points at it. The shape renders with an empty filter applied rather than with no filter, which is why designers report an uploaded icon looking flat or, in some renderers, disappearing. The attribute check is a substring match with no word boundary: `private function has_js_value( $value ) { return preg_match( '/base64|data|(?:java)?script|alert\(|window\.|document/i', $value ); }` ### What the substring match takes with it Any attribute value containing `data` or `document` anywhere inside it is removed. A font called Datalegreya, a class called `data-table`, an id called `documentation`, and every legitimate `data:image/png;base64` raster embedded in an icon. If you keep a design system of SVGs with utility classes on them, this is why your [Elementor custom colour rules](https://adityaarsharma.com/how-to-easily-add-custom-link-colors-in-elementor/) stop matching after upload. ## The surface that is still open Elementor inlines SVG icons. Its `Svg::get_inline_svg()` reads the sanitized markup out of the `_elementor_inline_svg` post meta and prints it into the page. So a `<style>` block that survived sanitising is not scoped to the icon. It is a stylesheet in your document. ### I built the case and read the computed styles I did not want to assert that from the specification, so I built the exact case and read the computed styles in Chrome: a page with an `h1` and a `p` outside the SVG, and a sanitizer-approved `<style>` block inside it. Both elements came back `rgb(255, 0, 0)`. `document.styleSheets` reported two sheets, one owned by the HTML `STYLE` node and one owned by the SVG `style` node. The SVG's style element becomes a second document stylesheet and its selectors match the whole page. An uploaded icon can restyle your header, hide your pricing table, or pull a font and a stylesheet from a third party server on every page view. No script needed, so no script scanner will flag it. That is the same shape of problem as [a malware scanner reporting clean while the site served spam to Googlebot](https://adityaarsharma.com/the-scanner-said-clean-the-site-was-serving-spam-to-googlebot/): the check was looking for the wrong artefact. ### Remote references survive too The remote reference case is the same story in a different attribute. `is_safe_href()` explicitly allows `http://` and `https://`, and `strip_xlinks()` only inspects the namespaced `xlink:href`, not the plain SVG 2 `href` that modern tooling emits. A `<image>` pointing at an external host goes through untouched. ## What I would actually do Three positions, in order of how much I trust them. - **Do not accept SVG uploads from anyone whose files you did not make.** This is not a cop-out, it is what Elementor's own documentation says, in the same page that tells you how to turn the feature on: upload files only from a trusted source. The sanitizer is a second line, not the first.- **If it is your own icon set, paste the markup instead of uploading the file.** Inline SVG in an HTML widget goes through your normal content pipeline, keeps its filter primitives and its data URIs, and never enters the media library where a future editor can reuse it by accident.- **If you must accept uploads, restore the elements you need and keep the ones you do not.** The camel-case mismatch is fixable from your own code without weakening the script rules.This is the filter. It adds a lowercase alias for every entry already on the list, so the lowercased tag name finds a match. It adds no element that was not already allowed. `add_filter( 'elementor/files/svg/allowed_elements', function ( array $elements ) { return array_values( array_unique( array_merge( $elements, array_map( 'strtolower', $elements ) ) ) ); } );`I ran the sanitizer with that filter in place, on the same payloads. `feGaussianBlur` and `feDropShadow` both come back inside their `<filter>` wrappers. The script payload and the `onload` payload still reduce to a bare `<svg>` with a `<rect>` in it. ### What the filter does and does not fix Filters come back. Script and event handlers stay removed, because they were never on the allow list to begin with and this filter only aliases what is already there. Put it in `wp-content/mu-plugins/` so a theme update cannot delete it. What that filter does not fix is the `<style>` block. If you are accepting SVGs from clients or from a form, strip style elements yourself on top of Elementor's pass, or do not accept the file. The same reasoning applies to any untrusted upload path, which is the argument for [putting Cloudflare Turnstile in front of Elementor forms](https://adityaarsharma.com/how-to-add-cloudflare-turnstile-to-elementor-forms-free-no-plugin/) before you worry about what arrives through them. ## The honest summary of the tradeoff Turning on SVG uploads in Elementor buys you: vector icons that scale, a colour picker that works when the fill is pure black, and files that are usually smaller than the PNG they replace. It costs you: a sanitizer that removes some of your design, a `<style>` element that can reach your whole page, remote references that survive, and a dependency on the PHP XML extension being present on every server the site ever runs on. ### When the trade is fine and when it is not If the icons are yours and the only people who can upload are you, that is a fine trade. If the media library is open to a client, an editor, or a form, it is not, and no toggle changes that. For pure file-weight reasons the answer is often not SVG at all: [bulk-compressing your existing WordPress images](https://adityaarsharma.com/how-to-compress-wordpress-images-in-bulk/) gets you most of the same page-weight win with none of this. And if you are reaching for SVG because you want a self-contained widget, the [no-plugin vCard build in Elementor](https://adityaarsharma.com/create-a-free-vcard-in-elementor/) is an example of doing it with markup you control instead. One thing to check today: run `finfo_file()` against an SVG on your production server and see which answer your libmagic gives. If it says `text/plain`, you now know why the snippet everyone recommends did not work for you. ## Resources - [wp_check_filetype_and_ext() in the WordPress developer reference](https://developer.wordpress.org/reference/functions/wp_check_filetype_and_ext/) (full source, including the MIME mismatch branch)- [wp_get_image_mime() in the WordPress developer reference](https://developer.wordpress.org/reference/functions/wp_get_image_mime/)- [wp-includes/capabilities.php at the WordPress 7.1 tag](https://github.com/WordPress/wordpress-develop/blob/7.1/src/wp-includes/capabilities.php) (the unfiltered_upload branch)- [Elementor Svg_Sanitizer source on GitHub](https://github.com/elementor/elementor/blob/main/core/utils/svg/svg-sanitizer.php)- [Elementor Uploads_Manager source on GitHub](https://github.com/elementor/elementor/blob/main/core/files/uploads-manager.php)- [Enable SVG Support in Elementor](https://elementor.com/help/enable-svg-support-in-elementor/) (Elementor documentation, last updated 16 June 2025) ## More on elementor - [Elementor Performance: What Actually Costs You, Measured](https://adityaarsharma.com/elementor-performance-what-actually-costs-you/)- [Elementor vs Gutenberg in 2026: An Honest Read for People Who Ship](https://adityaarsharma.com/elementor-vs-gutenberg-2026/)- [Custom Code in Elementor Without a Child Theme: What Survives an Update and What Does Not](https://adityaarsharma.com/elementor-custom-code-without-child-theme/) --- # Core Web Vitals for WordPress in 2026: The Thresholds, and What Actually Moves Them Source: https://adityaarsharma.com/core-web-vitals-wordpress-2026/ Same URL. Same PageSpeed report. Same second, 2 September 2026 at 21:32 IST. Desktop scored 96 with a Largest Contentful Paint of 1.0 seconds. Mobile scored 79 with an LCP of 4.5 seconds. Nothing about the site changed between those two numbers. Only the emulated device did. ![PageSpeed Insights desktop report for adityaarsharma.com showing performance 96, LCP 1.0 seconds, CLS 0.003, and No Data for field results.](https://adityaarsharma.com/wp-content/uploads/2026/09/03903416-6e70-424e-a40c-fd41a230a703_2800x2500-scaled.png)PageSpeed Insights, desktop tab, adityaarsharma.com. Screenshot taken 2 September 2026 from pagespeed.web.dev. Performance 96, LCP 1.0 s.![Largest Contentful Paint of 1.0 seconds on desktop against 4.5 seconds on mobile for the same URL.](https://adityaarsharma.com/wp-content/uploads/2026/09/f65f8a0a-498a-4d87-9724-670cc8922562_2912x1632-scaled.png)Built from the PageSpeed run at the top of this post, 2 September 2026 at 21:32 IST. Lab data. ### The gap is the device, not the site 4.5 seconds is a failing LCP. 1.0 second is a comfortable pass. If you have ever screenshotted your desktop score, you have shown someone a number that describes almost none of your traffic. That is the first thing to fix, and it is free. Below are the current thresholds with the date I read each page, where those numbers came from, and which of them a WordPress site can actually move. I checked every figure against Google's own documentation today rather than repeating what the tutorials say. ## The thresholds, as published, with dates ![The web.dev Web Vitals article showing the LCP, INP and CLS threshold bars with 2.5 and 4.0 seconds, 200 and 500 milliseconds, and 0.1 and 0.25.](https://adityaarsharma.com/wp-content/uploads/2026/09/df0b2983-f3c3-4da5-a255-cada84442233_2800x3400-scaled.png)web.dev/articles/vitals, screenshot taken 2 September 2026. The page header reads Published: May 4, 2020, Last updated: October 31, 2024. | Metric | Good | Needs improvement | Poor | Page and date I read it | | ------ | ---- | ----------------- | ---- | ----------------------- | | Largest Contentful Paint | 2.5 s or less | 2.5 to 4.0 s | over 4.0 s | web.dev/articles/lcp, last updated 4 September 2025 | | Interaction to Next Paint | 200 ms or less | 200 to 500 ms | over 500 ms | web.dev/articles/inp, last updated 2 September 2025 | | Cumulative Layout Shift | 0.1 or less | 0.1 to 0.25 | over 0.25 | web.dev/articles/cls, last updated 12 April 2023 | ### How the assessment actually works All three are assessed at the **75th percentile** of page loads, segmented across mobile and desktop. A page passes only if it meets all three at that percentile. Those numbers have not moved in 2026. The set has not gained a fourth metric. If someone tells you the thresholds changed this year, ask them for the page and the date. ### The one change: INP replaced FID in March 2024 The one real change since the last time most people looked is that Interaction to Next Paint replaced First Input Delay as a Core Web Vital on 12 March 2024. Google announced it on web.dev in a post last updated 31 January 2024, and was blunt about the consequence: FID was deprecated and removed from the programme, dropped from Search Console on the same day, with a six-month deprecation window in PageSpeed Insights and the Chrome User Experience Report. ## Why INP broke sites that had passed FID This is the part worth understanding, because it explains a specific pattern in WordPress: sites that were green for years suddenly went amber and nothing had been installed. ### What FID measured First Input Delay measured only the delay between the first interaction and the moment the browser began processing it. It stopped the clock before your code ran. A page could block the main thread for two seconds after the click and still post a fine FID, because the delay before processing was short. ### What INP measures instead Interaction to Next Paint measures the whole thing: input delay, plus the event handlers running, plus the browser painting the next frame that shows the result. And it does not take the first interaction, it effectively reports the worst one across the visit. ### What that catches on a WordPress site On a WordPress site the difference is concrete. A mega menu that runs a jQuery handler on click, a tabbed section from a page builder that rebuilds its markup, a search box that fires an admin-ajax request before painting anything, a cookie banner that recalculates layout: all of these passed FID and all of them are exactly what INP was built to catch. The metric did not get harsher. It started measuring the part that was always slow. ### Where 200 ms comes from Google's threshold document is honest that 200 ms is not arbitrary. It traces the number back through Jakob Nielsen's response-time limits to Miller, and to Michotte's 1962 work on the perception of causality, in which participants stopped perceiving one object as having caused another to move once the delay passed roughly 200 milliseconds. Past that, a click stops feeling like it did anything. ## Where the thresholds came from, which nobody quotes Google published its reasoning in *Defining the Core Web Vitals metrics thresholds*, last updated 7 May 2025. It is the most useful page in the whole set and almost nothing links to it. Three things in it change how you should read your own score. ### The good threshold is what good sites can hit **The good threshold is set by what good sites can actually hit.** For LCP the team tested 1.5, 2, 2.5 and 3 seconds against top-performing origins and found that *1.5 and 2 seconds are not consistently achievable, while 2.5 seconds is*. So 2.5 s is not a physiological limit. It is the tightest number that well-built sites reach at the 75th percentile. ![The share of origins classified poor at each chosen cut-off.](https://adityaarsharma.com/wp-content/uploads/2026/09/03a46ebe-c488-4ea7-b22b-1d8166f72f74_2912x1632-scaled.png)Built from the figures in Defining the Core Web Vitals metrics thresholds, web.dev, last updated 7 May 2025. ### The poor threshold is a quota **The poor threshold is set by a quota.** Google aims for 10 to 30 percent of origins to land in poor. At a 4 second LCP cut-off, roughly 26 percent of phone origins and 21 percent of desktop origins are classified poor, which is inside the target band, so 4 seconds was accepted. CLS at 0.25 puts about 20 percent of phone origins and 18 percent of desktop origins in poor. Failing is defined partly by where everyone else sits. ### CLS is lenient because of embeds **CLS is deliberately lenient, and the reason is third-party embeds.** Google's own testing found shifts of 0.15 and above were consistently perceived as disruptive, and CrUX data suggested 0.05 was a reasonable good threshold. They chose 0.1 anyway, writing that for embedded content the height is often unknown until it loads. Every WordPress site with a Twitter embed, a YouTube card or an ad slot is the reason your CLS budget is as generous as it is. ### Why the 75th percentile and not the 95th The 75th percentile itself was a trade. A higher percentile describes more of your visitors but gets dragged around by outliers: at the 95th, five bad samples out of a hundred visits decide your grade. At the 75th, it takes twenty-five. ## The number PageSpeed shows you is not the number Google grades you on Look at the top of my own report again. Under *Discover what your real users are experiencing*, it says **No Data**. Not slow, not fast. Nothing at all. ### What No Data means That is the Chrome User Experience Report saying my site does not have enough Chrome traffic to produce a field sample. Core Web Vitals thresholds are field thresholds, assessed on real visits at the 75th percentile. If CrUX has no data for your URL, none of the three metrics has a value for you in the sense Google uses. What you get instead is the lab section, and my report labels its own conditions in small grey text at the bottom of the metrics panel: > Captured at Sep 2, 2026, 9:32 PM GMT+5:30. Emulated Moto G Power with Lighthouse 13.4.1. Single page session. Using HeadlessChromium 151.0.7922.71. > > PageSpeed Insights run conditions, read from the report on 2 September 2026One page load. One emulated mid-range Android. One simulated network. That is a diagnostic, and a good one. It is not a measurement of your audience, and treating a lab score as a Core Web Vitals grade is the single most common mistake in WordPress performance work. ### Two consequences, both actionable Two consequences follow, and both are actionable. First, a small WordPress site will often never appear in Search Console's Core Web Vitals report at all, because CrUX needs volume. Second, the only way such a site gets real numbers is to collect them itself, from its own visitors, with the `web-vitals` library: `<script type="module"> import {onLCP, onINP, onCLS} from 'https://unpkg.com/web-vitals@4?module'; const send = m => navigator.sendBeacon('/wp-json/mysite/v1/vitals', JSON.stringify({name: m.name, value: m.value, id: m.id, rating: m.rating, path: location.pathname})); onLCP(send); onINP(send); onCLS(send); </script>`Three callbacks, a beacon, and a REST route to store them. Once that is running you have your own 75th percentile per template, which is the number that actually tells you whether a change worked. Everything before that is guessing with a nicer interface. ## What moves each metric on a WordPress site, and what does not Grouped by metric, because the mistake I see most often is applying an LCP fix to an INP problem and concluding that performance work does not help. | Metric | What moves it on WordPress | What does not | | ------ | -------------------------- | ------------- | | LCP | The hero image: correct size, `fetchpriority="high"`, and never lazy-loaded. Server or edge response time for the document. Render-blocking stylesheets in the head. | Minifying JavaScript. Deferring scripts that were already below the fold. An object cache, if your page cache is already hitting. | | INP | Third-party tags that run handlers. Page-builder interactivity, accordions, tabs, mega menus. admin-ajax calls fired on click. DOM depth, because style recalculation walks the tree. | Image compression. A CDN. Anything that only affects how fast bytes arrive rather than what runs after a click. | | CLS | Missing width and height on images and iframes. Web fonts swapping without a matched fallback metric. Cookie banners and ad slots injected above existing content. | Server speed. Caching of any kind. A faster host will shift the layout just as much, sooner. | ### The asymmetry in what gets sold to you The asymmetry is the useful bit. Almost every WordPress performance product sells you LCP work, because caching and image handling are easy to package. INP is the one that needs someone to open the performance panel and look at what a click actually costs, and CLS is usually a CSS defect rather than an infrastructure one. If you want to see the LCP half of this done live on a real site rather than described, I recorded a full pass: https://www.youtube.com/watch?v=iRaAQCRoyvYMy own channel. Live Speed Up WordPress Website Web Vitals with FlyingPress v5. A real site, the settings changed one at a time, and the metric re-measured after each. ### The best explanation of CLS is five years old For the CLS side, the clearest explanation I know of comes from the Chrome engineer who built the metric. Annie Sullivan's talk [Understanding Cumulative Layout Shift](https://youtu.be/zIJuY-JCjqw), posted on the estellevw channel from #PerfMatters Conference 2020, walks through why the score is impact fraction times distance fraction rather than a simple pixel count. It is cited by web.dev's own CLS article, which is how I found it. It is five years old and it has not been improved on. ## Three things I could not verify, so I am not claiming them - **Whether any of this affects your rankings, and by how much.** Google says page experience is a signal. Nobody outside Google can isolate its weight. Anyone showing you a ranking lift from a Core Web Vitals fix is showing you a correlation with a confound.- **My own field numbers.** CrUX returns No Data for adityaarsharma.com, so I have no 75th percentile to report for my own site. Every figure I gave for it is lab.- **What the thresholds will be next year.** Google's own Web Vitals page says the set will evolve and CLS documentation floats a stricter 0.05 in a future iteration. Floating is not announcing. ## Do this in the next ten minutes Open PageSpeed Insights on your most-visited template, not your homepage, and read only two things. First, whether the field section says No Data. If it does, your score is a lab simulation and your next move is to collect real numbers, not to change a setting. Second, the mobile LCP, ignoring the score entirely. ### The question that decides your week Then ask the question that actually decides your week: is my mobile LCP above 4.0 seconds? If it is, you have a document-delivery or hero-image problem and it is worth days of work. If it is under 2.5 and the site still feels slow, your problem is INP, and no amount of caching will touch it. ## Related reading - The measurements behind the numbers here, run with curl against this same site: [what actually makes a WordPress site slow](https://adityaarsharma.com/what-actually-makes-a-wordpress-site-slow/).- The hero image is the LCP element on most WordPress templates, and this is the free way to get its weight down: [how to compress WordPress images in bulk](https://adityaarsharma.com/how-to-compress-wordpress-images-in-bulk/).- Layout shift often starts as a CSS rule that never applied in the first place: [how to fix media query not working in WordPress](https://adityaarsharma.com/how-to-fix-media-query-not-working-in-wordpress/).- Unexplained gaps at the top of a template are a layout-shift source worth removing before you chase CLS: [how to remove top and bottom white space on WordPress](https://adityaarsharma.com/how-to-remove-top-and-bottom-white-space-on-wordpress/).- Redirect chains add a full round trip in front of the document and land directly on LCP: [how to fix 301 errors in WordPress](https://adityaarsharma.com/how-to-fix-301-errors-in-wordpress/). ## Resources - [Web Vitals](https://web.dev/articles/vitals), web.dev, last updated 31 October 2024.- [Defining the Core Web Vitals metrics thresholds](https://web.dev/articles/defining-core-web-vitals-thresholds), web.dev, last updated 7 May 2025. Every number in the reasoning section above comes from here.- [Largest Contentful Paint](https://web.dev/articles/lcp), last updated 4 September 2025.- [Interaction to Next Paint](https://web.dev/articles/inp), last updated 2 September 2025.- [Cumulative Layout Shift](https://web.dev/articles/cls), last updated 12 April 2023.- [Interaction to Next Paint becomes a Core Web Vital on March 12](https://web.dev/blog/inp-cwv-march-12), last updated 31 January 2024.- [GoogleChrome/web-vitals](https://github.com/GoogleChrome/web-vitals), the library in the field-measurement snippet.- [Chrome User Experience Report](https://developer.chrome.com/docs/crux), the field dataset behind every threshold. --- # Canonical Tags on WordPress: What Breaks Them, and How to Audit Yours Source: https://adityaarsharma.com/canonical-tags-what-breaks-them-on-wordpress/ I ran this against my own site on 3 September 2026 and the second page of my blog archive has no canonical tag at all. ![Canonical tags WordPress core prints on an archive, a search page or any paginated listing.](https://adityaarsharma.com/wp-content/uploads/2026/09/8365b3e1-22c5-4d27-a1a8-48ce5eb4dc59_2400x2400.png)`$ curl -s https://adityaarsharma.com/page/2/ | grep -o '<link rel="canonical"[^>]*>' $ curl -s https://adityaarsharma.com/page/2/ | grep -o '<meta name="robots"[^>]*>' `Same on the search results page. Every single post URL has one, correctly self-referential, and correctly stripping a `?utm_source` I appended by hand. The archives have none. That is not a bug on my site, it is two design decisions stacking, and tracing them is the fastest way to understand how canonical tags actually get onto a WordPress page. ![The rel_canonical function reference page on developer.wordpress.org.](https://adityaarsharma.com/wp-content/uploads/2026/09/3bd33ae1-874e-4035-8a92-d9948249e2e8_2880x1800-scaled.png)developer.wordpress.org/reference/functions/rel_canonical/, screenshot taken 3 September 2026. ## Where the tag comes from in core One line in `wp-includes/default-filters.php` does it: `add_action( 'wp_head', 'rel_canonical' );`. The function itself lives in `wp-includes/link-template.php`. `function rel_canonical() { if ( ! is_singular() ) { return; } // ... then get_queried_object_id(), then wp_get_canonical_url( $id ), // then echo the link element. }` ### Core emits on singular pages only Read the first three lines again. **Core emits a canonical tag on singular pages only.** Your home page, your category archives, your author archives, your date archives, your search results and every paginated version of all of them get nothing from core. ### What wp_get_canonical_url actually returns Ever. That is the whole story of the missing tag on `/page/2/`, and it has been true since 2.9.0. `wp_get_canonical_url()` itself is short. It refuses anything whose `get_post_status()` is not `publish`, builds the URL from `get_permalink()`, rewrites it for in-post pagination when `get_query_var( 'page' )` is 2 or more, replaces it outright with `get_comments_pagenum_link()` when a comment page is requested, and returns it through the `get_canonical_url` filter. Two of those behaviours surprise people. - **Anything not in `publish` status returns false.** A privately published page, a scheduled post being previewed, a custom status from an editorial workflow plugin: no canonical tag. If you use a plugin that adds post statuses, your canonical coverage has holes shaped exactly like that plugin.- **Pagination inside a post is canonical to itself, and comment pagination overrides everything.** Split a long post with the `<!--nextpage-->` tag and page 2 of that post canonicalises to page 2 of that post, not to page 1. That is correct behaviour, and it is the opposite of what most "consolidate your paginated content" advice tells you to do. If a comment page is requested, `get_comments_pagenum_link()` wins outright.![One canonical on a single post, none on the blog archive page 2, none on search results.](https://adityaarsharma.com/wp-content/uploads/2026/09/24cf88b7-589e-4ae3-a160-140bece72260_2912x1632-scaled.png)Measured on adityaarsharma.com with curl, 3 September 2026. ## The moment an SEO plugin takes the tag over Both of the plugins that matter remove core's function outright with `remove_action( 'wp_head', 'rel_canonical' );`. Yoast SEO 28.5 does it in `src/integrations/front-end-integration.php` and again in `src/integrations/front-end/indexing-controls.php`. Rank Math 1.0.277.2, in `includes/frontend/class-head.php`, does the same thing and then makes a second decision that explains the archive on my own site. ### Why my own archives have no canonical Its `robots()` method prints the robots meta, and if that meta contains `noindex` it calls `remove_action` on its own `rank_math/head` hooks for `canonical` at priority 20 and `adjacent_rel_links` at 21. My site runs Rank Math. Rank Math sets paginated archives to `follow, noindex` by default, and its own code then removes the canonical on any page it has marked noindex. Both halves are defensible. The comment above that block in Rank Math's source cites a Search Engine Roundtable piece on the confusion caused by combining `noindex` with `rel=canonical`, and a page you have already excluded from the index does not need a canonical to consolidate anything. ### What that means for every canonical question So my archives are behaving as designed, and I would not change it. The reason to know this is different: it means the canonical tag on a WordPress site is **entirely a plugin output** the moment an SEO plugin is active. Core's function is not running. So every question about canonical behaviour on your site is a question about that plugin's settings, not about WordPress. ## The four things that break canonicals in practice ### 1. Query strings, and what core does and does not strip Core's `redirect_canonical()` in `wp-includes/canonical.php` normalises host, scheme, trailing slashes and multiple slashes, and issues a 301 when the result differs from what was requested. What it does not do is drop query arguments it does not recognise. ### Why the parameter survives the redirect It builds the redirect target from the parsed original and appends `$redirect["query"]` back on, then compares the original and redirect host, path, port and query arrays and bails if they are the same. So `/post/?utm_source=newsletter` does not redirect anywhere. It serves a 200 with the full content. What saves you is the canonical tag, which is built from `get_permalink()` and therefore carries no query string at all. ### And why archives have no such protection I fetched `/how-to-fix-301-errors-in-wordpress/?utm_source=test` on my own site and the canonical came back as the clean URL, with the parameter gone. That works on singular pages. On archives, where there is no canonical tag from core, every campaign parameter, every faceted filter and every session ID is a separate crawlable URL with identical content. ### The mistake Google names explicitly This is the specific reason [blocking WooCommerce add-to-cart URLs from crawling](https://adityaarsharma.com/how-to-prevent-woocommerce-add-to-cart-dynamic-urls-from-crawling/) is worth doing on any store: the add-to-cart parameters generate exactly this pattern at volume. ### 2. Pagination, in three different flavours WordPress has three separate pagination systems and they behave differently: - **Archive pagination** (`/category/x/page/2/`): no canonical from core, plugin-dependent, usually noindexed.- **Post pagination** (`<!--nextpage-->`): self-referential canonical from core, handled by the `$page >= 2` branch above.- **Comment pagination** (`?cpage=2` or `/comment-page-2/`): overrides the canonical entirely with `get_comments_pagenum_link()`.The common mistake is pointing every paginated archive page at page 1. Google's own pagination guidance says the opposite in two sentences: "Don't use the first page of a paginated sequence as the canonical page. ### The leftovers outlive the plugin ### Both fail the same way Instead, give each page its own canonical URL." If your SEO plugin is set to canonicalise archives to the first page, you are telling Google that pages 2 through 40 of your blog are duplicates of page 1, which they are not. ![Google Search Central on pagination: give each page its own canonical URL.](https://adityaarsharma.com/wp-content/uploads/2026/09/f7613c7d-4b00-4689-9452-6be4edcada49_2400x3000-scaled.png)Quoted from Google Search Central, pagination and incremental page loading, read 3 September 2026. ### 3. AMP leftovers The official AMP plugin, version 2.5.5, is careful here. Its `ensure_required_markup()` checks `empty( $links['canonical'] )` first and only appends a canonical node to the head when nothing else has written one. And Rank Math, in the same head class, removes the AMP plugin's own canonical action when AMP is active so the two do not both fire. So a current, correctly configured AMP install is not the problem. The problem is the leftovers, and they outlive the plugin: - `/amp/` URLs that are still indexed after you deactivated the plugin, now returning a 404 or a soft 404 rather than redirecting.- `<link rel="amphtml">` tags baked into a page cache, a CDN edge cache or a theme header that somebody edited by hand.- A second AMP plugin from a different vendor still installed and still writing its own canonical, so the page ships two canonical elements pointing at different URLs. Google's documentation does not say what it does with two conflicting canonical elements on one page, and I have not seen it tested. Which is the point: you have made a statement you cannot predict the effect of, for no benefit.Check for the double tag rather than assuming: `curl -s URL | grep -c 'rel="canonical"'` should return exactly 1. ### 4. Everything else that writes to wp_head Because the canonical is printed by a hook, anything hooked into `wp_head` can add a second one, and any output buffering layer, page cache or optimisation plugin can rewrite the first one. Two patterns worth checking: - **A page cache serving a stale head.** Change a canonical, purge nothing, and the old tag is served for as long as the cache lives.- **Optimisation plugins rewriting URLs.** Anything that rewrites asset or page URLs, a CDN plugin included, can rewrite the `href` of the canonical too if its replacement is not scoped.Both fail the same way: the HTML in your theme is right and the HTML on the wire is wrong. Which is why every audit below fetches the page rather than reading the code. ## Auditing every canonical on a site Start from the sitemap, because it is the list of URLs you claim to care about. The audit is five steps and you can run it as a shell loop or in a spreadsheet. ### The three findings, ranked If you would rather do this in a spreadsheet, [pulling a sitemap into Google Sheets](https://adityaarsharma.com/how-to-extract-links-from-websites-using-sitemap-in-sheets/) does the enumeration step with a formula and no terminal. - Expand the sitemap index into individual sitemaps, then into URLs, with `grep -oP '(?<=<loc>)[^<]+'`.- Fetch each URL with `curl -sL --max-time 20`, one at a time, with a `sleep 1` between requests.- Count the canonical elements: `grep -c 'rel="canonical"'`.- Pull the first href: `grep -oP '(?<=rel="canonical" href=")[^"]+' | head -1`.- Bucket the result. Zero tags is MISSING, one tag matching the URL is SELF, one tag pointing elsewhere is POINTS-TO, anything above one is DUPLICATE.One second between requests, sequentially, against your own server. Do not parallelise this against a site you do not own. Three findings to expect, in descending order of how much they matter: - **DUPLICATE.** Two plugins both writing a canonical. Fix first, because it is the only one of the three findings whose outcome is genuinely unpredictable.- **POINTS-TO a URL that then redirects.** A canonical pointing at a URL that 301s is a two-hop instruction and Google follows it, but you are burning crawl budget and the signal weakens. This is the overlap with [fixing 301 errors in WordPress](https://adityaarsharma.com/how-to-fix-301-errors-in-wordpress/), and the audit above will surface it as a canonical whose target is not a 200.- **MISSING.** Check whether the page is also noindexed. If it is, this is by design and you can leave it. If it is indexable and has no canonical, that is the real finding.Add the target check with a second pass: `curl -s -o /dev/null -w '%{http_code} %{url_effective}\n' -L "$CANONICAL_TARGET"`. Anything that does not report 200 with the same URL you sent is a canonical pointing at a redirect. ## What a canonical tag will not do Google's own documentation ranks the signals: redirects are "a strong signal", `rel=canonical` is "a strong signal", sitemap inclusion is "a weak signal". And then, plainly: "none of them are required; your site will likely do just fine without specifying a canonical preference", because absent one, Google picks. ### What it settles, and what it does not A canonical is a preference, not an instruction, and Google will overrule it when the pages are not actually duplicates. Which means a canonical tag cannot fix thin duplicate content, cannot consolidate pages that are genuinely different, and cannot be used as a substitute for deciding which page should exist. It settles which of several near-identical URLs gets the credit. That is the whole job. The sitemap side of the same signal set is worth reading together with this, because both are statements you make about which URLs are real: [core versus plugin sitemaps](https://adityaarsharma.com/wordpress-sitemaps-core-vs-plugin/). ## Do this today Run this one line against three URLs on your site: your home page, one post, and page 2 of your blog archive. `for u in https://example.com/ https://example.com/some-post/ https://example.com/page/2/; do echo -n "$u " curl -s "$u" | grep -c 'rel="canonical"' done`If the home page returns 0 you have found something worth fixing in the next ten minutes. If any of them returns 2, fix that first. ## Resources - [wp_get_canonical_url(), WordPress Developer Resources](https://developer.wordpress.org/reference/functions/wp_get_canonical_url/)- [rel_canonical(), WordPress Developer Resources](https://developer.wordpress.org/reference/functions/rel_canonical/)- [redirect_canonical(), WordPress Developer Resources](https://developer.wordpress.org/reference/functions/redirect_canonical/)- [How to specify a canonical URL, Google Search Central](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls)- [Canonicalization overview, Google Search Central](https://developers.google.com/search/docs/crawling-indexing/canonicalization)- [Pagination and incremental page loading, Google Search Central](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading)- [RFC 6596, The Canonical Link Relation](https://www.rfc-editor.org/rfc/rfc6596) --- # Automating WordPress maintenance with agents: what is worth automating and what is not Source: https://adityaarsharma.com/automating-wordpress-maintenance-with-agents/ Run the integrity check that every WordPress maintenance guide recommends on a site with twelve plugins, five of them commercial, and this is the shape of what comes back. The message strings, the counting and the exit code are from the WP-CLI source, which I read on 2 September 2026. The plugin names and the counts are an illustration: ![The WordPress integrity check exits zero on the plugins it never checked.](https://adityaarsharma.com/wp-content/uploads/2026/09/5ceb27eb-abf5-4f1b-93c8-146eca1efcf4_2912x1632-scaled.png) - `--dry-run` shows what would change and changes nothing.- `--patch` is the narrowest bar that still ships security fixes.- `--minor` takes minor releases and holds back majors.- `--exclude` takes everything except the plugins the whole site is built on. `$ wp plugin verify-checksums --all Warning: Could not retrieve the checksums for version 5.9.2 of plugin acme-pro, skipping. Warning: Could not retrieve the checksums for version 3.1.0 of plugin acme-forms, skipping. Success: Verified 7 of 12 plugins (5 skipped). $ echo $? 0` Exit code zero. Five plugins were never checked. If your automation branches on the exit code, and most automation does, that site just passed an integrity check that examined fifty-eight per cent of its plugin code. ### Why the exit code is zero ![Seven plugins verified, five skipped, on a site with twelve installed.](https://adityaarsharma.com/wp-content/uploads/2026/09/5362942c-96a9-4044-8b03-a2e124fc26b5_2912x1632-scaled.png)Built from the run at the top of this post. Message strings, counting and exit code read from the WP-CLI source on 2 September 2026. That is not a bug. The command downloads checksums from WordPress.org, and commercial plugins are not on WordPress.org, so there is nothing to compare against. The handling in `Checksum_Plugin_Command.php`, in `wp-cli/checksum-command`, is four lines: when `$checksums` comes back false it calls `WP_CLI::warning()` with that skipping message, increments `$skips`, and continues. And in `report_batch_operation_results()` in WP-CLI core, skips only affect the wording. A skip appends `({$skips} skipped)` to the message. When there are no outright failures the function calls `WP_CLI::success()`, which exits zero. Only the failures branch calls `WP_CLI::error()`, and that is the branch that exits non-zero. ### The shape of the problem ![The exit code is zero even after five plugins were never checked.](https://adityaarsharma.com/wp-content/uploads/2026/09/723e8132-3090-4f07-be39-b249377796e3_2400x2400.png)wp plugin verify-checksums --all, then echo $?. WP-CLI source read 2 September 2026. This is the shape of almost every honest answer about automating WordPress maintenance. The task is automatable. The thing you wanted to know is one layer behind the output, and an agent that reads only the summary line will confidently tell you the site is fine. ## The split I actually use Everything below assumes the agent has wp-cli over SSH and read access, set up the way I described in [running Claude Code against WordPress](https://adityaarsharma.com/running-claude-code-against-wordpress-the-complete-setup/), and the credential limits from [the guardrails post](https://adityaarsharma.com/guardrails-agent-production-wordpress/). The split is not about capability. An agent can run every command here. It is about which decisions have bounded consequences. | Task | Automate? | Why | | ---- | --------- | --- | | Gathering update, integrity and cron state | Yes | Read-only, and the output is what a human needs to decide | | Reading changelogs and classifying risk | Yes | Language work with a written artefact you can check | | Detecting file drift against WordPress.org | Yes, with the skip caveat handled | Deterministic comparison | | Post-change verification sweeps | Yes | Cheap, repeatable, catches the thing you did not think of | | Deciding whether to apply a major update | No | Unbounded consequence on a page-builder site | | Fleet-wide scheduled updates | No, use a fleet tool | Already solved, with rollback and reporting you would rebuild badly | | Restoring a backup | No | One shot, and the failure mode is losing the good copy | | Cleaning a compromised site | No | Requires knowing what normal looked like before | | Renewing or dropping a plugin licence | No | Commercial decision with a client attached | ## Worth automating: the state-gathering pass This is the highest-value thing an agent does with a WordPress site, and it is entirely read-only. One script, one JSON blob, and the agent reasons over it instead of running twenty commands and losing the earlier output to context pressure. Mine runs six things and prints one object. - `wp plugin list --format=json --fields=name,status,update,version`, and the same for `wp theme list`.- `wp core check-update --format=json`, falling back to an empty array when it writes nothing.- `wp plugin verify-checksums --all`, with stdout and stderr sent to two different files, because the warnings are the finding.- `wp core verify-checksums --include-root`, which is the one that reports files sitting in the WordPress root that do not belong to WordPress.- A count of the skip warnings: `grep -c 'skipping\.$'` over the warning file, stored as `plugins_not_verifiable`.- One `jq -n` call that slurps the three JSON files and the count into a single object keyed by site. ### The one field that changes behaviour The field that changes behaviour is `plugins_not_verifiable`. Any number above zero means the integrity check has a hole in it exactly the size of your commercial plugins, which are also the plugins most likely to have been nulled, patched by hand, or installed from a marketplace that is not the vendor. Feed that number to the agent and tell it to treat a non-zero value as a finding rather than a footnote. ### The root-file check nobody runs The other half of that gap is `wp core verify-checksums --include-root`, which warns about files in the WordPress root that do not belong to WordPress. That is a genuinely useful signal and it is one of the few automated checks that would have caught the situation I wrote about when [a scanner reported a site clean while it served spam to Googlebot](https://adityaarsharma.com/the-scanner-said-clean-the-site-was-serving-spam-to-googlebot/). ## Worth automating: update triage, which is not the same as updating The distinction that makes this safe: the agent produces the plan, a human approves it, then the agent executes the approved plan. WP-CLI gives you four flags to do that properly. `wp plugin update --all --dry-run wp plugin update --all --patch wp plugin update --all --minor wp plugin update --all --exclude=elementor,elementor-pro` `--dry-run`, `--patch`, `--minor` and `--exclude` are all documented options on `wp plugin update`. The `--exclude` flag is the one that turns this from a gamble into a policy: the plugins your layout depends on go on the exclusion list permanently and get handled by a human with a staging copy. ### Where the agent earns its keep Where the agent earns its keep is the layer above the flags. Given the dry-run output, it can pull each plugin's changelog, sort the updates into security, bug fix, feature and breaking, and tell you which three of the eighteen you actually need to think about. That is language work with a written artefact you can check, which is the shape of task agents are reliably good at. ### Do not stack an agent on a broken auto-update setup If auto-updates are meant to be handling this and are not, that is a separate diagnosis with its own set of causes, and I have written it up in [fixing WordPress automatic updates that are not working](https://adityaarsharma.com/how-to-fix-wordpress-automatic-updates-not-working/). Do not stack an agent on top of a broken auto-update setup and assume the agent is the fix. ## Worth automating: the verification sweep after a change Updates rarely fail loudly. They fail by turning one template into a blank section, or by dropping a widget that only appears on the pricing page. The check is boring, repeatable and exactly what you should hand to a machine. Capture the status code and the byte size of every page that matters before the update, do it again after, then join the two files and print any row where the status changed or the size dropped below ninety per cent of what it was. ### What this catches and what it does not Byte size within ten per cent is a crude proxy and it will produce false positives on anything with a rotating element. It also catches the failure that matters most, which is a page that still returns 200 while rendering a fraction of its content. Pair it with a real render check on a handful of templates and you have covered most of what goes wrong. ## Not worth automating: the update go/no-go on a site you did not build The mechanism is worth stating plainly, because it explains several of the rows in that table at once. ### Retrievable evidence against evidence that is nowhere An agent is good at tasks where the evidence is retrievable and the consequence of being wrong is a bad paragraph. It is bad at tasks where the evidence lives in a place nothing can query. Whether a major update to a page-builder addon breaks a client's homepage depends on which of forty widgets that homepage uses, which of them the addon rewrote, and whether the site has custom CSS targeting the old markup. None of that is in the changelog. Some of it is not written down anywhere. So the agent gathers, classifies and recommends. A human with the site open decides. On sites where a broken layout means a phone call, that division has never felt like a limitation to me. ## Not worth automating: the fleet update loop, because it already exists This is the part where I will argue against building the thing. If you manage twenty client sites, the job of pushing updates across them with a rollback point, a per-site log and a report the client can read is a solved problem, and the solutions have had years of edge cases beaten out of them. ### Where the fleet tools actually differ I have run the feature comparisons across this category in detail. The short version of where the differences actually land: - [The eight WordPress site management tools worth looking at](https://adityaarsharma.com/best-8-wordpress-management-tools/) is the starting point if you have not picked one.- [MainWP against ManageWP](https://adityaarsharma.com/main-wp-vs-manage-wp/) is the self-hosted-dashboard versus hosted-service decision, and it changes where your agent can reach.- [WP Remote against MainWP](https://adityaarsharma.com/wp-remote-vs-main-wp/) and [MainWP against InfiniteWP](https://adityaarsharma.com/main-wp-vs-infinite-wp/) cover the rest of the self-hosted side.- [ManageWP against WP Umbrella](https://adityaarsharma.com/manage-wp-vs-wp-umbrella/) and [WP Umbrella against UpdraftCentral](https://adityaarsharma.com/wp-umbrella-vs-updraft-central/) are the ones to read if update safety and backup handling are what you care about. ### The arrangement that works The useful pattern is not agent instead of fleet tool. It is the fleet tool doing the mechanical push, and the agent doing the reading and the reporting that the fleet tool does not do: reading changelogs, correlating an error spike with a specific update, drafting the client-facing note. The commercial side of that arrangement, including who pays for which licence, is its own conversation and I covered it in [managing WordPress plugin subscriptions for clients](https://adityaarsharma.com/managing-wordpress-plugin-subscriptions-for-clients/). ## Not worth automating: restores, and cleanups A restore is one shot with an unbounded downside. The realistic failure is not a wrong command, it is a right command against the wrong snapshot, and by the time anyone notices, the good copy has been written over. A human runs restores. ### Why cleanup is worse than a restore Cleaning a compromised site fails for a different reason. Removing malware requires knowing what the file was supposed to contain, and on a site with a decade of plugins and a hand-edited theme, nothing has that baseline. `wp core verify-checksums` gives it to you for core. `wp plugin verify-checksums` gives it to you for WordPress.org plugins and, as established at the top, silently gives you nothing for the rest. An agent asked to clean a site with an incomplete baseline will delete things that were supposed to be there. ### What the agent can do in an incident What the agent can do in an incident is collect evidence fast: modified file timestamps, the checksum diff, admin users created in the last month, scheduled events that do not belong to any installed plugin, and the raw access log lines around the first modified file. That is a triage pack, and it makes the human faster without giving the machine a delete button. ![The WP-CLI command reference page for wp plugin verify-checksums.](https://adityaarsharma.com/wp-content/uploads/2026/09/14c80f83-accd-4a41-8433-27bca434bd91_2880x1800-scaled.png)developer.wordpress.org/cli/commands/plugin/verify-checksums/, screenshot taken 3 September 2026. ## The one number to check on your own fleet this week Run this on every site you maintain: `wp plugin verify-checksums --all 2>&1 \ | grep -c 'skipping\.$'` That count is how many plugins on that site have no integrity baseline at all. Not how many are compromised. How many you could not tell either way. On most agency fleets it is not zero, and until now the number was probably invisible because the command exited zero and printed the word Success. ### The fix is a policy Once you have the number, the fix is a policy rather than a script: keep a known-good copy of every commercial plugin zip you install, checksum it yourself at install time, and compare against that. It is the same idea WordPress.org gives you for free, applied to the half of your site that WordPress.org does not cover. If you are doing this across client sites, the process side matters as much as the tooling, and [how to manage WordPress websites for clients](https://adityaarsharma.com/how-to-manage-wordpress-websites-for-clients/) covers how I structure that. And if you want the underlying detail on what an agent can reach over HTTP rather than over SSH, including why there is no plugin update route in core at all, that is in [the post on the REST API as an agent surface](https://adityaarsharma.com/wordpress-rest-api-agent-surface/). ## Resources - [wp plugin verify-checksums](https://developer.wordpress.org/cli/commands/plugin/verify-checksums/)- [wp-cli/checksum-command, including wp core verify-checksums --include-root](https://github.com/wp-cli/checksum-command)- [wp plugin update and its dry-run, patch, minor and exclude options](https://developer.wordpress.org/cli/commands/plugin/update/)- [wp plugin auto-updates](https://developer.wordpress.org/cli/commands/plugin/auto-updates/)- [report_batch_operation_results() in WP-CLI core](https://github.com/wp-cli/wp-cli/blob/main/php/utils.php)- [The full WP-CLI command reference](https://developer.wordpress.org/cli/commands/) Disclosure: I work on product and growth at POSIMYTH, which ships WordPress plugins across 500,000+ installs, and our commercial plugins are exactly the kind that `verify-checksums` skips. That is the honest reason I know the gap is there. ## More on agents on wordpress - [MCP servers for WordPress: what actually exists and what each one can do](https://adityaarsharma.com/mcp-servers-for-wordpress-what-exists/)- [The WordPress REST API as an agent surface: what you can and cannot automate](https://adityaarsharma.com/wordpress-rest-api-agent-surface/)- [Guardrails for running an agent against a production WordPress site](https://adityaarsharma.com/guardrails-agent-production-wordpress/) --- # The Abilities API in WordPress core: what it is and what ships with it Source: https://adityaarsharma.com/abilities-api-wordpress-core/ Here is WordPress 6.9.7 answering a request for one of the three abilities that core itself registers: `$ curl -s -u "admin:APP_PASSWORD" \ http://localhost:8892/wp-json/wp-abilities/v1/abilities/core/get-user-info/run {"code":"rest_ability_not_found","message":"Ability not found.","data":{"status":404}}` The ability exists. `wp_get_ability( 'core/get-user-info' )` returns a `WP_Ability` object. It is simply not reachable over HTTP on that version, and no error tells you why. On WordPress 7.1 the same request returns the user object. I went through the Abilities API against the source and a running site, because the writing about it does not match what shipped. Everything below was checked against the 6.9.7 and 7.1 release zips from wordpress.org, and against one `wp-env` install whose core I swapped between those two versions, on 2 September 2026. WordPress 7.1 is the current release as of that date, confirmed from the `api.wordpress.org` version-check endpoint. ## What core actually registers: three abilities, all read-only Every registration lives in one file, `wp-includes/abilities.php`, in a single function hooked by `wp-includes/default-filters.php` with `add_action( 'wp_abilities_api_init', 'wp_register_core_abilities' );`. That function registers two categories and three abilities. Nothing else in core registers an ability. Here is the whole surface: | Ability | Category | Permission callback | Annotations | | ------- | -------- | ------------------- | ----------- | | `core/get-site-info` | site | `current_user_can( 'manage_options' )` | readonly, not destructive, idempotent | | `core/get-user-info` | user | `is_user_logged_in()` | readonly, not destructive, idempotent | | `core/get-environment-info` | site | `current_user_can( 'manage_options' )` | readonly, not destructive, idempotent | ### Three abilities, two categories, zero writes All three read. None of them write. If you were expecting core to ship an ability that creates a post or installs a plugin, it does not, on any version through 7.1. The write surface is whatever plugins register, and the transport to an AI client is a separate plugin, the WordPress MCP Adapter, announced on the developer blog in February 2026. ![The developer.wordpress.org code reference page for wp_register_ability showing the signature and the three registration steps.](https://adityaarsharma.com/wp-content/uploads/2026/09/c81bbd2c-53e8-469e-b8b0-97686c942071_2800x1800-scaled.png)developer.wordpress.org/reference/functions/wp_register_ability/, screenshot taken 2 September 2026. ### Which version shipped the functions `wp_register_ability()` is core as of 6.9.0. The docblock in `wp-includes/abilities-api.php` carries `@since 6.9.0` on it and on `wp_unregister_ability()`, `wp_has_ability()`, `wp_get_ability()`, `wp_get_abilities()` and the four category functions. ## The finding: 6.9 exposes two of the three over REST, 7.1 exposes all three In the 6.9.7 source, each ability carries a `meta` array, and `core/get-user-info` has one line the other two do not: `// wp-includes/abilities.php, WordPress 6.9.7, line 196 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), 'show_in_rest' => false, ),` `show_in_rest` is false. The other two abilities do not set it at all. ### One meta key decides whether HTTP can see it Both REST controllers gate on that value. `WP_REST_Abilities_V1_List_Controller` and `WP_REST_Abilities_V1_Run_Controller` each contain the same line: `if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) { return new WP_Error( 'rest_ability_not_found', ... 404 ); }`. A hidden ability is indistinguishable from a missing one. So on a real 6.9.7 install the list endpoint returns two entries, `core/get-site-info` and `core/get-environment-info`. The third name is absent. The categories endpoint still returns both `site` and `user`, so a client that walks categories first will find a user category with nothing in it. ### What 7.1 changed In 7.1 the meta changed. All three abilities now carry `'public' => true` instead of an explicit `show_in_rest`, and `WP_Ability` resolves one from the other. Line 370 of `wp-includes/abilities-api/class-wp-ability.php` reads `$args['meta']['show_in_rest'] = $args['meta']['show_in_rest'] ?? $args['meta']['public'] ?? self::DEFAULT_SHOW_IN_REST;`. An explicit `show_in_rest` still wins, then `public` seeds it, then the default. The practical result: on 7.1 the list returns all three and `core/get-user-info` runs. I checked the same install on both versions by swapping core underneath it with `wp core download --version=6.9.7 --force` and back, and the behaviour flips with the version. ### What to set if you support both If you are registering abilities in a plugin today and you want them reachable on 6.9 as well as 7.1, set `show_in_rest` explicitly. `public` alone does nothing on 6.9, because 6.9 never reads that key. ## The route in the announcement post does not exist The WordPress Developer Blog article that introduced this API, by Jonathan Bossenger on 14 November 2025, gives the run endpoint as `/wp-json/wp-abilities/v1/{namespace/ability}/run`. ![The WordPress Developer Blog article Introducing the WordPress Abilities API, showing the author, the November 2025 date, and a table of contents that includes Installing the Abilities API.](https://adityaarsharma.com/wp-content/uploads/2026/09/a418e850-7be9-489c-949d-6221cfbd3d14_2800x2000-scaled.png)developer.wordpress.org/news/2025/11/introducing-the-wordpress-abilities-api/, screenshot taken 2 September 2026. That was written when the Abilities API was a Composer package you installed yourself, which is what the article's Installing section describes. After the core merge the controllers gained a `rest_base`, and it is not empty. The run controller sets `$namespace = 'wp-abilities/v1'` and `$rest_base = 'abilities'`, then registers the route as `/abilities/(?P<name>[a-zA-Z0-9\-\/]+?)/run`. So the real path has an extra segment. I ran both against a live WordPress 7.1 install. The path from the announcement post, `/wp-json/wp-abilities/v1/core/get-site-info/run`, returns 404. The path core actually registers, `/wp-json/wp-abilities/v1/abilities/core/get-site-info/run`, returns 200. ### The two 404s mean different things The 404 body is `rest_no_route`, not `rest_ability_not_found`, which is the tell. `rest_no_route` means WordPress never matched a route at all. `rest_ability_not_found` means the route matched and the ability was hidden or missing. Two different problems, two different fixes, and an agent retry loop that treats them the same will burn tokens re-sending a request that can never work. The same distinction applies across the rest of the surface, which I went through route by route in the writeup on [the WordPress REST API as an agent surface](https://adityaarsharma.com/wordpress-rest-api-agent-surface/). ## Read-only abilities require GET, and POST returns 405 The run controller registers `WP_REST_Server::ALLMETHODS`, then decides the correct verb from the ability's annotations. That is `validate_request_method()`, and the mapping is three lines long. - `readonly` true, the expected method is GET.- `destructive` and `idempotent` both true, the expected method is DELETE.- anything else, the expected method is POST. Because all three core abilities are `readonly`, the only verb they accept is GET. POST gets rejected before the permission callback runs, with `rest_ability_invalid_method`, the message "Read-only abilities require GET method." and HTTP 405. ### Why the verb check is a 405 and not a 404 There is a comment above the route registration explaining why it is done this way rather than with per-method routes: at `rest_api_init` time WordPress does not yet know which abilities exist, because plugins have not registered theirs. The comment names the Feature API as having hit the same problem. That is a load-order constraint, and it is why the verb check is a runtime 405 rather than a routing 404. ### Where the input goes Input goes in an `input` parameter: a query parameter for GET and DELETE, a JSON body key for POST. Filtering the site info to two fields means a GET with `input[fields][]=name` and `input[fields][]=version` as repeated query keys, which comes back as `{"name":"code-copy","version":"7.1"}`. ## The 6.9 input bug that 7.1 fixed Plenty of HTTP clients serialise an array query parameter as a comma-joined string rather than repeated bracket keys. On WordPress 6.9.7 that is not handled. Sending `input[fields]=name,version` throws `Warning: foreach() argument must be of type array|object, string given` at `wp-includes/abilities.php` line 115, followed by a headers-already-sent warning from `class-wp-rest-server.php` line 1909, and an empty array as the body. Be precise about what that is. The install was `wp-env`, which sets `WP_DEBUG` and `WP_DEBUG_DISPLAY` to true by default, so the warnings render. On a production site with display errors off you get an empty array and no explanation, which is arguably worse to debug. Either way the ability does not run and the response is not the shape the schema promises. ### The 7.1 fix WordPress 7.1 added `coerce_input_to_schema()` and `sanitize_input_for_ability()` to the run controller, both marked `@since 7.1.0`, with a comment that says exactly why: GET and DELETE deliver every scalar as a string and a list as a single comma-separated string. The same comma-joined request on 7.1 returns `{"name":"code-copy","version":"7.1"}`. ## The registry is lazy, so this costs nothing when nobody asks ![Core registers three abilities and none of them writes.](https://adityaarsharma.com/wp-content/uploads/2026/09/77e22e24-fee0-40f1-b85c-e0aba7deb81e_2912x1632-scaled.png)Built from the registrations in wp-includes/abilities.php, read from the 6.9.7 and 7.1 release zips on 2 September 2026. The performance question people ask first is whether every page load now pays for an ability registry. It does not. `wp_abilities_api_init` is fired from inside `WP_Abilities_Registry::get_instance()`, so it only runs when something actually reaches for the registry. ### What I measured I put a probe in an mu-plugin to log `did_action( 'wp_abilities_api_init' )` at shutdown, then hit three URLs. - `/?p=5`, a normal front-end page load: `fired=no`.- `/wp-json/wp-abilities/v1/abilities`: `fired=yes`.- `/wp-json/wp/v2/posts`, a generic REST request: `fired=no`. A front-end page load does not fire it. A generic REST request does not fire it. Only touching the abilities surface does. That is the right design and it is worth knowing before someone tells you to disable the API for performance. ## Registering your own, and the mistake that silently fails ![The wp_register_ability code reference page on developer.wordpress.org.](https://adityaarsharma.com/wp-content/uploads/2026/09/4addc820-fb03-48ed-af20-4640069b792e_2880x1800-scaled.png)developer.wordpress.org/reference/functions/wp_register_ability/, screenshot taken 3 September 2026. The one rule that will cost you an afternoon: `wp_register_ability()` checks `doing_action( 'wp_abilities_api_init' )` and bails if you are not inside it. It returns null and fires `_doing_it_wrong()` with the message "Abilities must be registered on the wp_abilities_api_init action", versioned `6.9.0`. Register on `init` and you get null and a notice you will never see with debugging off. A working registration hangs off that hook and passes these keys. - `label` and `description`, both translated.- `category`, a slug that must already be registered.- `input_schema` and `output_schema`, plain JSON Schema. Set `additionalProperties` to false unless you mean it.- `execute_callback`, which receives the validated input array.- `permission_callback`, mandatory, and enforced on the REST path as well as in PHP.- `meta`, holding `annotations` plus `show_in_rest` and `public`. ### The two keys to set, and the category trap Both `show_in_rest` and `public` are set on purpose: the first is what 6.9 reads, the second is what 7.1 prefers. The category slug has to already exist or registration fails, so register it with `wp_register_ability_category()` on the same hook if you are not reusing `site` or `user`. WordPress 7.1 also gave `wp_get_abilities()` an `$args` parameter for filtering by category, namespace prefix or nested meta, plus two new filters, `wp_get_abilities_item_include` and `wp_get_abilities_result`. If you are building a directory or a picker UI over abilities, that is what to use rather than filtering the full array yourself. ## Reproduce this in about four minutes Everything above came out of `@wordpress/env`, which ships real containers rather than a bundled runtime. The stack is `mariadb:lts` for the database, an image built `FROM wordpress` for the web server, one built `FROM wordpress:cli` for WP-CLI, and `phpmyadmin` if you turn it on. Four steps get you there. - Write a `.wp-env.json` with `"core": "WordPress/WordPress"` and a port of your own, then run `npx @wordpress/env@11.14.0 start`.- Mint a password with `npx @wordpress/env@11.14.0 run cli wp user application-password create admin probe --porcelain`.- List what is exposed: `curl -s -u "$AUTH" http://localhost:8892/wp-json/wp-abilities/v1/abilities | jq -r '.[].name'`.- Run one: `curl -s -u "$AUTH" http://localhost:8892/wp-json/wp-abilities/v1/abilities/core/get-environment-info/run`. ### Two things that cost me time Two things that cost me time. First, `wp-env start` fails outright if 8888 or 8889 are taken by another `wp-env` project, with `Bind for 0.0.0.0:8889 failed: port is already allocated`. Pass `--auto-port` or pin ports in the config. Second, `wp-env run cli wp core download --version=6.9.7` silently drops the `--version` flag, because `wp-env` consumes it as its own. Go around it with `docker exec -u www-data <cli container> wp core download --version=6.9.7 --force --skip-content --path=/var/www/html`, then `wp core update-db`. ## What I would not conclude from this yet Three abilities, all read-only, is a foundation rather than a feature. The interesting question is what plugins register, and I have not seen enough registrations in the wild to say anything about that. Nobody has published numbers on how many plugins ship abilities, and I am not going to invent any. What I will say is that the shape is right. Permission callbacks are mandatory and are enforced on the REST path. Annotations drive the HTTP verb rather than being documentation. The registry is lazy. Those are the parts that decide whether this is safe to expose to an agent, and they were designed rather than bolted on. It sits alongside the rest of what landed in this cycle, which I covered in [the piece on WordPress 7.0 being the biggest change since Gutenberg](https://adityaarsharma.com/wordpress-7-0-is-the-biggest-thing-to-happen-since-gutenberg-and-nobodys-talking-about-it/). ## One thing to do in the next ten minutes Run this against whatever install you already have, with an application password: `curl -s -u "user:app password" \n "https://YOURSITE/wp-json/wp-abilities/v1/abilities" | jq -r '.[].name'` Two names back means you are on 6.9 and `core/get-user-info` is registered but hidden. Three means 7.1 or later. An empty array or a `rest_no_route` means the namespace is not there at all and you are below 6.9. That one command tells you which of the three behaviours in this post applies to your site, and it is the first thing to check before you debug anything else. If you have not wired an agent to a WordPress install yet, [the complete Claude Code against WordPress setup](https://adityaarsharma.com/running-claude-code-against-wordpress-the-complete-setup/) is the prerequisite. ## Resources - [wp_register_ability() in the WordPress code reference](https://developer.wordpress.org/reference/functions/wp_register_ability/), the canonical signature and registration steps.- [Introducing the WordPress Abilities API](https://developer.wordpress.org/news/2025/11/introducing-the-wordpress-abilities-api/), Jonathan Bossenger, WordPress Developer Blog, 14 November 2025. Read it for the design intent, and check the REST paths against your own install before copying them.- [From Abilities to AI Agents: Introducing the WordPress MCP Adapter](https://developer.wordpress.org/news/2026/02/from-abilities-to-ai-agents-introducing-the-wordpress-mcp-adapter/), WordPress Developer Blog, February 2026.- [wp-includes/abilities.php in wordpress-develop](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/abilities.php), the file that contains every core registration.- [WordPress/abilities-api on GitHub](https://github.com/WordPress/abilities-api), the feature repository the core merge came from.- [@wordpress/env on npm](https://www.npmjs.com/package/@wordpress/env), version 11.14.0 at the time of writing. --- # The WordPress Autoload Set: What Loads on Every Request, and the Guard That Usually Does Not Apply Source: https://adityaarsharma.com/wordpress-options-table-autoload-audit/ This is the function that decides whether an option in your database is loaded on every single request. It is `wp_determine_option_autoload_value()`, in `wp-includes/option.php`, lines 1306 to 1339. I read it in WordPress 7.1, downloaded from wordpress.org on 3 September 2026 and confirmed against `api.wordpress.org/core/version-check/1.7/`, which reports 7.1 as current. ![Two thresholds, five options apart.](https://adityaarsharma.com/wp-content/uploads/2026/09/b4eb0139-e132-4733-be40-de67c98a0796_2912x1632-scaled.png) `if ( is_bool( $autoload ) ) { return $autoload ? 'on' : 'off'; } switch ( $autoload ) { case 'on': case 'yes': return 'on'; case 'off': case 'no': return 'off'; } // only now: $autoload = apply_filters( 'wp_default_autoload_value', null, ... );` Look at where the filter is. It is at the bottom. Every branch above it returns before the filter ever runs. That matters because the 150,000 byte guard everyone credits WordPress 6.6 with is a callback on that filter: `wp_filter_default_autoload_value_via_option_size()`, added at priority 5 in `wp-includes/default-filters.php` line 299. ### The guard applies to exactly one case So the guard applies to exactly one case: a caller who passes something that is neither a boolean nor one of the four recognised strings. In practice that means a caller who passes `null`, or omits the argument entirely. ## Four ways to write a multi-megabyte option that autoloads anyway All four of these skip the size check completely. The option can be five megabytes and it will still be marked as autoloaded. `add_option( 'my_huge_thing', $five_megabytes, '', true ); // is_bool -> 'on' add_option( 'my_huge_thing', $five_megabytes, '', 'yes' ); // switch -> 'on' add_option( 'my_huge_thing', $five_megabytes, '', 'on' ); // switch -> 'on' update_option( 'my_huge_thing', $five_megabytes, true ); // is_bool -> 'on'` ### What the dev note says, and what it leaves out The 6.6 dev note on make.wordpress.org, published 18 June 2024, says it this way: "For any options that do not explicitly pass `true` to the `$autoload` parameter, a value that is greater than 150k bytes will no longer be set to autoload." That sentence is accurate but it names only one of the four routes. The string `'yes'` is the one that catches people, because it is what every plugin written before 6.6 passes, and it is still the documented legacy value. ![Quote card carrying the WordPress 6.6 dev note line about options that do not explicitly pass true.](https://adityaarsharma.com/wp-content/uploads/2026/09/210ab995-2a1a-499f-a20a-544179a5682e_2400x3000-scaled.png)The sentence in full, from the WordPress 6.6 dev note on make.wordpress.org, published 18 June 2024. ## The bigger hole: existing rows are never re-evaluated This is the part I did not expect. In `update_option()`, lines 949 to 961, when no autoload argument is passed, WordPress reads the current `autoload` column and only re-evaluates it if the value is in `$allow_values`. ### yes is not in the allow list `$allow_values` contains `auto-on`, `auto-off` and `auto`. It does not contain `yes`. It does not contain `on`. So if a row in your options table has `autoload = 'yes'`, which is what every row written before WordPress 6.6 has, and a plugin calls `update_option( $name, $huge_value )` without an autoload argument, WordPress reads the current value, sees `'yes'`, decides it is not eligible for re-evaluation, and writes the new value while leaving `autoload = 'yes'` in place. The size guard never runs. It will never run on that row, on any future update, for the life of the site. ### The reasoning, and the consequence The reasoning is defensible: `'yes'` means a human or a plugin author made a deliberate choice, and core should not silently overrule it. The consequence is that on a site upgraded from 6.5 or earlier, the 6.6 improvement only ever applies to options created after the upgrade. The old ones are frozen exactly as they were. ## What "the autoload set" actually means at query time The read side is `wp_load_alloptions()`, same file, line 600. It runs one `SELECT option_name, option_value` filtered by `autoload IN (...)` before almost anything else on every request. If that query returns nothing, the next line selects the entire options table. Two things there are worth stopping on. ### Four column values count as autoloaded First, `wp_autoload_values_to_autoload()` at line 3264 returns `array( 'yes', 'on', 'auto-on', 'auto' )`. Four distinct column values count as autoloaded. Note that `auto` is in that list: an option where nothing made a decision, and the size guard did not fire, gets `auto` and **is** loaded. ### The fallback loads everything Second, the fallback on the next line. If the autoload query returns an empty result, WordPress selects the entire options table. That is a safety net for a broken or half-migrated schema, and on a normal site it never fires. But it means the failure mode of a corrupted autoload column is not "no options load", it is "every option loads on every request", which is the exact opposite of what you would want and much harder to notice. ## The two thresholds are not the same number, and neither is a limit The per-option guard is 150,000 bytes, filterable through `wp_max_autoloaded_option_size`, defined in `wp_filter_default_autoload_value_via_option_size()` at line 1362. The Site Health warning is a different number in a different file. `WP_Site_Health::get_test_autoloaded_options()` in `wp-admin/includes/class-wp-site-health.php` line 2709 uses 800,000 bytes, filterable through `site_status_autoloaded_options_size_limit`, and it measures the total by summing `strlen()` across everything `wp_load_alloptions()` returned. So five options sitting just under the per-option guard total 750,000 bytes and Site Health still reports the site as acceptable. Neither number is enforced. The 150,000 one changes a default; the 800,000 one changes a message in an admin screen. Nothing in WordPress refuses to autoload anything. ![Bar chart of the 800,000 byte Site Health warning against the 150,000 byte per-option autoload guard.](https://adityaarsharma.com/wp-content/uploads/2026/09/11188234-e5a9-4b8c-829d-e60eb26566b7_2912x1632-scaled.png)Built from the two thresholds in this post, read from WordPress 7.1 on 3 September 2026. ## Measuring it, and why the obvious command undercounts The command everyone reaches for is `wp option list --autoload=on --format=total_bytes`. It is in the WP-CLI docs and it is on every performance blog. It undercounts, twice, and neither undercount is documented in the command help. The filter it builds, in `src/Option_Command.php` from wp-cli/entity-command v3.0.2, released 14 August 2026, matches only `autoload='on'` or `autoload='yes'`. ### Two values, not four Every option written since 6.6 without an explicit autoload argument carries `auto` or `auto-on`, and WordPress loads all of them, and this command counts none of them. The second undercount is a few lines further down in the same method: the query appends `option_name NOT LIKE '_transient_%'` and the site-transient equivalent. `wp option list` excludes transients unless you pass `--transients`. A transient set with no expiry is autoloaded, permanently, and this command hides it by default. That is a large enough hole on its own that I gave it a separate post. ## Commands that give you the real number Query the column values core actually loads, rather than the two WP-CLI matches. All three of these run through `wp db query` against your options table: - **The real total.** `SUM(LENGTH(option_value))` and `COUNT(*)` where `autoload IN ('yes','on','auto-on','auto')`. That is exactly what `wp_load_alloptions()` returns.- **The top offenders, transients included.** Same filter, selecting `option_name`, `autoload` and `LENGTH(option_value)`, ordered by bytes descending, limit 25.- **The distribution.** The same table grouped by `autoload`, which tells you how much of it predates 6.6. Run all three. The third one is the one that answers the question. `wp db query "SELECT autoload, COUNT(*) AS n, SUM(LENGTH(option_value)) AS bytes FROM $(wp db prefix --allow-root)options GROUP BY autoload ORDER BY bytes DESC;"` ### Reading the distribution If almost every row says `yes`, the site was installed before 6.6 and none of the 6.6 machinery has touched it. If you see a healthy mix of `auto` and `auto-off`, the guard has been doing its job on new writes. Turning one off is a single command, and it takes effect on the next request because the `alloptions` cache key is rebuilt: `wp option set my_huge_thing "$(wp option get my_huge_thing)" --autoload=off`. That round-trips the value through the shell, which will mangle a serialized array. For anything non-scalar, run an `UPDATE ... SET autoload='off'` against the row directly and then `wp cache flush`. ![The WordPress 6.6 dev note on make.wordpress.org, Options API: disabling autoload for large options.](https://adityaarsharma.com/wp-content/uploads/2026/09/e1f2cab3-09d7-4b83-8c18-168f279f551b_2800x2000-scaled.png)make.wordpress.org, dev note published 18 June 2024, screenshot taken 3 September 2026. ## What does not work **Deleting rows you did not write.** An option marked autoload is not the same as an option that is unused. Felix Arntz, who did much of the 6.6 autoload work, makes this point directly in [his write-up on autoloading options](https://felix-arntz.me/blog/autoloading-wordpress-options-efficiently-and-responsibly/): there is no guarantee an autoloaded option is ever read, and equally no way to prove from the row alone that it is not. Setting `autoload='off'` is reversible. Deleting is not. **Raising `wp_max_autoloaded_option_size`.** The dev note says increasing it is not recommended, and the reason is structural rather than advisory. The cost of an autoloaded option is not the disk read, it is that the whole set is serialized into one object cache entry and unserialized on every request. Raising the threshold makes that entry bigger for every request on the site, including the ones that never touch the option. **Assuming an object cache fixes it.** It moves the work rather than removing it. With Redis or Memcached in front, the `SELECT` disappears but the payload still crosses the socket and still gets unserialized in PHP on every request. If you are not sure which layer your problem is in, I worked through how to tell them apart in [the piece on WordPress caching layers](https://adityaarsharma.com/wordpress-caching-layers-which-one-is-failing/). **Treating this as the whole performance story.** Autoloaded options are a fixed per-request tax. They are rarely the biggest number on a slow site. On [a site I profiled end to end](https://adityaarsharma.com/what-actually-makes-a-wordpress-site-slow/) the query count and the plugin hook weight both mattered more. The reason to fix autoload anyway is that it is cheap and it is measurable, which is not true of most of the rest. ## If you run WooCommerce A store has a different shape of the same problem, and the expensive parts are queries rather than options. I covered the specific ones, including the five leading-wildcard `LIKE` comparisons the admin product search runs with no `LIMIT`, in [the WooCommerce performance breakdown](https://adityaarsharma.com/woocommerce-performance-queries-that-cost-you/). Do not run both audits at once and try to attribute the result; measure the options table first because it is the one you can change without touching a query. One option worth checking on any store, or any site with a lot of scheduled work: `cron`. It is written by `_set_cron_array()` in `wp-includes/cron.php` as `update_option( 'cron', $cron, true )`. Boolean `true`. It is one of the four bypass routes above, by design, and on a site with tens of thousands of queued events it can be the largest autoloaded row in the table. If yours is large, [the comparison of WP-Cron against Action Scheduler](https://adityaarsharma.com/wordpress-cron-vs-action-scheduler/) covers why, and what moving the work to a table changes. ## The one thing to do Run the distribution query above on your largest site. If more than about half the bytes sit in rows where `autoload = 'yes'`, the 6.6 guard has never applied to your site and never will, and every one of those rows is a decision somebody made years ago that nothing has revisited since. ## Resources - [Options API: disabling autoload for large options](https://make.wordpress.org/core/2024/06/18/options-api-disabling-autoload-for-large-options/), the WordPress 6.6 dev note, 18 June 2024- [wp_determine_option_autoload_value()](https://developer.wordpress.org/reference/functions/wp_determine_option_autoload_value/) in the code reference, marked private- [wp_load_alloptions()](https://developer.wordpress.org/reference/functions/wp_load_alloptions/) in the code reference- [wp option list](https://developer.wordpress.org/cli/commands/option/list/), the WP-CLI command documentation- [wp-includes/option.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/option.php) on wordpress-develop- [Autoloading WordPress options efficiently and responsibly](https://felix-arntz.me/blog/autoloading-wordpress-options-efficiently-and-responsibly/), Felix Arntz --- # WordPress Image Formats in 2026: What Core Does, and Bytes I Measured Myself Source: https://adityaarsharma.com/wordpress-image-formats-webp-avif-2026/ WordPress 7.1 does not convert your JPEGs to WebP. It does not convert them to AVIF either. The default output format map in core is four lines long and it maps HEIC to JPEG, which is the opposite direction from the one everybody assumes. Here is the whole function, from `wp-includes/media.php` in the 7.1 tarball I downloaded on 3 September 2026: ![AVIF beat WebP on the screenshot and lost on the photograph.](https://adityaarsharma.com/wp-content/uploads/2026/09/60985c91-fa78-4d67-a530-4a2fffd3e36e_2912x1632-scaled.png) `function wp_get_image_editor_output_format( $filename, $mime_type ) { $output_format = array( 'image/heic' => 'image/jpeg', 'image/heif' => 'image/jpeg', 'image/heic-sequence' => 'image/jpeg', 'image/heif-sequence' => 'image/jpeg', ); return apply_filters( 'image_editor_output_format', $output_format, $filename, $mime_type ); }` A JPEG upload is not in that map, so it comes out the other end as a JPEG. A PNG comes out as a PNG. The docblock records the only change that ever happened: *@since 6.7.0 The default was changed from an empty array to an array containing the HEIC/HEIF images mime types*. Before 6.7 the default was nothing at all. So if a post tells you core generates WebP automatically now, that post is wrong, and it has been wrong through several release cycles. What core actually gives you is the ability to *read* WebP and AVIF, to serve them if you upload them, and one filter to change the conversion policy. The conversion itself is a plugin's job. ## What core does do with the modern formats ### Three things core does do **It accepts them as uploads.** `image/avif` and `image/webp` are in the allowed mime map in `wp-includes/functions.php`, and both image editors handle them. `WP_Image_Editor_GD` has explicit branches for `image/avif` gated on `function_exists( 'imagecreatefromavif' )`, and `WP_Image_Editor_Imagick` has its own. Whether AVIF works on your host is a question about your PHP build, not about WordPress. **It can read their dimensions without PHP support.** `wp_getimagesize()` falls back to parsing the file headers itself when the PHP build cannot: there is a `wp_get_avif_info()` helper in core that pulls width and height straight out of the AVIF container. That fallback is what lets an AVIF upload still get correct `width` and `height` attributes on the front end, which matters more than it sounds, because [core refuses to make any loading or priority decision on an image with no dimensions](https://adityaarsharma.com/wordpress-lazy-loading-what-core-does/). **It sets different default quality per format.** This one is buried in `WP_Image_Editor::get_default_quality()` and almost nobody knows it. The switch has one case, `image/webp`, which returns 86. Everything else falls through to `$this->default_quality`, which is 82. Those are the numbers a WordPress site will actually encode at, so those are the numbers I measured with. ## The storage multiplication, counted on a real upload Before comparing formats it is worth being precise about how many files one upload becomes, because the conversion decision multiplies against this number. A stock install registers six sizes. Four come from options set in `wp-admin/includes/schema.php`: | Size | Width | Height | | ---- | ----- | ------ | | thumbnail | 150 | 150 | | medium | 300 | 300 | | medium_large | 768 | 0 | | large | 1024 | 1024 | Two more are added in code by `_wp_add_additional_image_sizes()`, present since 5.3.0: `1536x1536` and `2048x2048`, described in the source as 2x medium_large and 2x large. Sizes larger than the original are skipped, which you can verify from outside with `curl -o /dev/null -w "%{http_code} %{size_download}"` against each size. My own avatar is a 1000 by 1000 upload, so only the three smaller sizes exist: | File | Status | Bytes | | ---- | ------ | ----- | | `Aditya-Sharma.jpg` | 200 | 108,189 | | `Aditya-Sharma-150x150.jpg` | 200 | 2,887 | | `Aditya-Sharma-300x300.jpg` | 200 | 8,143 | | `Aditya-Sharma-768x768.jpg` | 200 | 47,493 | Fetched from adityaarsharma.com on 3 September 2026, two seconds apart. Four files, 166,712 bytes, from one upload. On a 3000 pixel wide photograph all six sizes generate, plus core's own scaled copy: `big_image_size_threshold` defaults to 2560, and anything wider gets a `-scaled` version created and served as the full size while the true original is kept alongside. That is eight files for one upload before a single theme size is registered, and a theme with four custom crops takes it to twelve. ### Why the multiplication decides your strategy The multiplication is the part that decides your conversion strategy. Generating both a modern format and a fallback for every registered size doubles that number. The Modern Image Formats plugin makes exactly this call, and it makes it the right way round, which I will come back to. ## Honest byte comparisons, measured on real files Every WebP-versus-AVIF article quotes a percentage from somebody's benchmark. I converted two of my own files at the exact quality settings WordPress core would have used, with the encoders on my laptop, and got a result that contradicts the received wisdom on one of them. The tools, versioned: `cwebp` 1.6.0 with libsharpyuv 0.4.2, and `avifenc` 1.4.1 with dav1d 1.5.3 and aom 3.13.3. ### File one: a photograph The 1000 by 1000 JPEG above, 108,189 bytes, exported from Photoshop: | File | Bytes | Against the source | | ---- | ----- | ------------------ | | `hero.jpg` | 108,189 | source | | `hero-q86.webp` | 72,920 | -32.6% | | `hero-q82.avif` | 90,528 | -16.3% | Encoded with `cwebp -q 86` and `avifenc -q 82 --speed 6`, the qualities core would use. AVIF produced a file 24% larger than WebP on the same source at WordPress's own default quality settings. If your mental model is that AVIF is always smaller, that result should bother you. Mine did. ### File two: a screenshot A 2800 by 3000 PNG, a full-page screenshot of the Query Monitor listing on wordpress.org. Flat colour, sharp text, one photographic banner in the middle. 1,122,260 bytes: | File | Bytes | Against the source | | ---- | ----- | ------------------ | | `graphic.png` | 1,122,260 | source | | `graphic-q86.webp` | 332,514 | -70.4% | | `graphic-q82.avif` | 210,410 | -81.3% | Same two encoders and quality numbers as the photograph. Here AVIF wins, and wins clearly: 37% smaller than the WebP of the same image. Same two encoders, same two quality numbers, opposite conclusion. ![Bar chart: hero.jpg at 108,189 bytes, the WebP at 72,920 and the AVIF at 90,528.](https://adityaarsharma.com/wp-content/uploads/2026/09/aacf83ac-5103-4525-9558-c714047cbae2_2912x1632-scaled.png)Built from the byte counts measured in this post on 3 September 2026. ## Why the two files disagree, which is the part worth keeping Three mechanisms, and none of them is a benchmark result you have to take on trust. ### The quality scales are not the same scale `cwebp -q 86` and `avifenc -q 82` are two encoders' internal knobs. They are not calibrated against each other, and neither is calibrated against JPEG's quality number. WordPress core treats them as interchangeable because a single integer is what its API exposes. A fair comparison would fix a perceptual quality target such as SSIM or Butteraugli and find the bitrate each encoder needs to hit it. I did not do that here, and any post that quotes a single AVIF-versus-WebP percentage without saying how it equalised quality is quoting a number that means nothing. I am telling you what WordPress will actually produce with its own defaults, which is a different and more useful question than which codec is better. ### The photograph was already lossy hero.jpg is a JPEG that has already been through a DCT quantiser. Re-encoding it stores the artifacts as if they were image detail, and high-quality settings preserve them faithfully and expensively. This is exactly what WordPress does when a plugin converts an uploaded JPEG, so the number is representative rather than a mistake, but it does mean the comparison is between two re-encodes and not between two encodes of an original. ### AVIF's advantages are structural They suit graphics. AVIF inherits AV1's intra-prediction modes and its much larger coding block sizes. Flat regions and hard edges, which is what a screenshot is made of, compress extremely well under that model. Photographic noise does not, and that is where the cheaper, older WebP model stops losing. This is why the two files disagree and why a single blanket percentage cannot be right for both. ![Bar chart: graphic.png at 1,122,260 bytes, the WebP at 332,514 and the AVIF at 210,410.](https://adityaarsharma.com/wp-content/uploads/2026/09/4e889775-6ca6-4db7-afc0-6ecca7d1b7d1_2912x1632-scaled.png)Built from the byte counts measured in this post on 3 September 2026. ## The cost nobody puts in the comparison: encode time Timed with `/usr/bin/time -p` on the same 2800 by 3000 image: `cwebp` took 0.39 seconds real, `avifenc --speed 6` took 0.86. Roughly twice as long on a 2800 by 3000 image, on an idle laptop, at `--speed 6`. Lower the speed number for better compression and it gets slower fast. Now multiply that by six registered sizes, and by two if you also generate fallbacks, and run it inside a PHP upload request on shared hosting with a 30 second execution limit. That is the mechanism behind the upload timeouts people report after turning on modern format generation, and it is why the honest answer for a large existing library is to regenerate in the background with WP-CLI rather than at upload time. ## The plugin that actually does the conversion The WordPress Performance Team ships it. It is called Modern Image Formats, its slug is still `webp-uploads` from when it was called WebP Uploads, and I pulled its numbers from `api.wordpress.org/plugins/info/1.2/` rather than the listing page. Version 2.7.1, 100,000 active installs, tested against 7.1, last updated 26 August 2026. Its own description page states the policy plainly: AVIF is generated if the hosting server supports it, otherwise WebP, and the choice is exposed under Settings then Media when both are available. ### Two behaviours people get caught by First, only new uploads are converted; existing images stay as they are until you regenerate them, with `wp media regenerate` or a plugin. Second, and this is the good design decision, by default only the *sub-sizes* become modern format. The originally uploaded file stays a JPEG or PNG. There is an *Output fallback images* checkbox that generates both formats for every sub-size, and turning it on is what doubles your storage. That default is the right way round. The original is your archive copy and the thing you regenerate from, so it should stay in the format with the widest possible tool support. The sub-sizes are disposable derivatives, so they should be in whatever format is smallest today. ![The Modern Image Formats plugin listing on WordPress.org, slug webp-uploads.](https://adityaarsharma.com/wp-content/uploads/2026/09/0570019d-6131-442e-81d2-eb52c8093468_2800x2000-scaled.png)wordpress.org/plugins/webp-uploads/, screenshot taken 3 September 2026. ## Doing it yourself with the filter, and when not to If you want the behaviour without the plugin, the filter is one callback. Hook `image_editor_output_format`, then set `$formats['image/jpeg']` and `$formats['image/png']` to `'image/webp'`. Return the array core gave you with your entries added, rather than a fresh one. Replacing it wholesale drops the HEIC and HEIF mappings, and then an iPhone photo uploaded through the media library stops being converted to something browsers can display. ### Three reasons not to do it by hand First, core's REST attachment controller deliberately disables the filter in places, with `add_filter( 'image_editor_output_format', '__return_empty_array', 100 )` around image editing operations, so that editing an image does not silently change its format; you would need to reproduce that care. Second, your host may support WebP in GD but not AVIF, and you get to find out in production. Third, there is no fallback generation, so a visitor on a browser without WebP support gets nothing. The plugin handles all three. Use the filter when you want to understand the mechanism, use the plugin when you want it to work. ## What does not work **Converting everything to AVIF because a chart said it is smallest.** My photograph got 24% larger than the WebP. Convert a sample of your own library and count bytes before committing. **Generating both formats for every size, everywhere.** That is six sizes times two formats plus two originals for one upload. If your library is 20 gigabytes you now need 40, and your backups take twice as long. Turn on fallbacks only if your analytics show browsers that need them. **Converting at upload time on shared hosting.** The encode-time numbers above are from a laptop with nothing else running. Do the initial pass with WP-CLI over SSH. **Assuming a smaller file is a faster page.** On my own homepage the heaviest asset by a wide margin is a third-party JavaScript file, not an image, which is what [measuring the whole asset list](https://adityaarsharma.com/what-actually-makes-a-wordpress-site-slow/) showed. Image format work is worth doing and it is rarely the largest number on the page. [The Core Web Vitals piece](https://adityaarsharma.com/core-web-vitals-wordpress-2026/) has the framing for deciding which number to chase first, and [the bulk compression walkthrough](https://adityaarsharma.com/how-to-compress-wordpress-images-in-bulk/) covers the pass you should run before any of this. ## One thing to do next Take the largest image in your uploads directory and convert it twice, at the exact qualities core would use: `cwebp -q 86 -quiet yourfile.jpg -o out.webp avifenc -q 82 --speed 6 yourfile.jpg out.avif ls -l yourfile.jpg out.webp out.avif` If AVIF is smaller, your library is probably graphics and screenshots and you should prefer AVIF. If WebP is smaller, your library is photographs and you should prefer WebP. Repeat on four or five files before you set a site-wide policy, because one file is an anecdote. If you need to sweep a lot of attachments once you have decided, [the bulk editing over the REST API post](https://adityaarsharma.com/bulk-edit-wordpress-rest-api/) covers doing that without breaking things. ## Resources - [Modern Image Formats on wordpress.org](https://wordpress.org/plugins/webp-uploads/), the Performance Team plugin. Version 2.7.1, 100,000+ installs, updated 26 August 2026.- [image_editor_output_format](https://developer.wordpress.org/reference/hooks/image_editor_output_format/), the one filter that changes conversion policy.- [wp media regenerate](https://developer.wordpress.org/cli/commands/media/regenerate/), for converting an existing library outside a web request.- [cwebp command line reference](https://developers.google.com/speed/webp/docs/cwebp), from the libwebp project.- [libavif on GitHub](https://github.com/AOMediaCodec/libavif), which ships the avifenc binary used above.- [caniuse: AVIF support](https://caniuse.com/avif), for deciding whether you still need fallback images. --- # Two Schedulers on One Site: WP-Cron Against Action Scheduler Source: https://adityaarsharma.com/wordpress-cron-vs-action-scheduler/ Two schedulers run on a WooCommerce site, and only one of them uses a database table. Here is the other one, in full, from `wp-includes/cron.php` in WordPress 7.1: ![The cleanup batch went from 20 to 250.](https://adityaarsharma.com/wp-content/uploads/2026/09/ccd088b2-bdf7-4f73-8113-e0c1fe2f973f_2912x1632-scaled.png) `$cron['version'] = 2; $result = update_option( 'cron', $cron, true );` Every scheduled event on the site is one PHP array, serialized into one row of `wp_options`, rewritten in full every time anything is scheduled or unscheduled. The third argument to `update_option()` is boolean `true`, so that row is autoloaded on every request. That is the design. Everything else about WP-Cron follows from it. I read WordPress 7.1, downloaded from wordpress.org on 3 September 2026, and Action Scheduler 4.1.0 from the woocommerce/action-scheduler repository on the same day. ## What the option-array model costs Three consequences, all of them structural rather than incidental. ### Read whole, written whole **It is read whole and written whole.** `_get_cron_array()` calls `get_option( 'cron' )` and returns the entire structure. There is no way to query for one hook. Scheduling one event means unserializing every event, adding to the array, reserializing and writing the lot back. ### Exempt from the 6.6 size guard **It is exempt from the 6.6 autoload size guard.** WordPress 6.6 added a 150,000 byte threshold above which an option is no longer autoloaded by default. That guard sits on the `wp_default_autoload_value` filter, and passing boolean `true` returns from `wp_determine_option_autoload_value()` before the filter is ever reached. On a site with a large queue the `cron` option can be megabytes and still be loaded on every request. I traced that whole mechanism in [the autoload audit](https://adityaarsharma.com/wordpress-options-table-autoload-audit/). **Deduplication is a linear scan.** `wp_schedule_single_event()` builds `md5( serialize( $event->args ) )` and walks every timestamp in the array looking for a match within ten minutes either side. Two events that differ only in argument order, or in an integer versus a numeric string, hash differently and both get scheduled. The core docblock warns about this directly. Measure yours before assuming it is fine: - `wp db query` for `LENGTH(option_value)` and `autoload` on the row where `option_name='cron'`. That is the byte cost you pay on every request.- `wp cron event list --format=count` for the number of scheduled events.- `wp cron event list --fields=hook,next_run_relative` to see what is actually queued. ![The actionscheduler.org documentation site.](https://adityaarsharma.com/wp-content/uploads/2026/09/34ed05ac-4aac-4932-80e3-48b54fe16bfd_2800x2000-scaled.png)actionscheduler.org, screenshot taken 3 September 2026. ## Action Scheduler stores rows instead Three tables, defined in `classes/schema/ActionScheduler_StoreSchema.php` at schema version 8. The one that does the work is `actionscheduler_actions`. Its columns are `action_id`, `hook`, `status`, `scheduled_date_gmt` and `scheduled_date_local`, `priority`, `args`, `schedule`, `group_id`, `attempts`, the two `last_attempt` columns, `claim_id` and `extended_args`. ### Nine indexes, and what they buy Nine secondary indexes sit on top of that, keyed on hook plus status plus date, on status plus date, on date alone, on args, on group, on last attempt, on claim plus status plus priority plus date, on status plus last attempt, and on status plus claim. That is what buys you the things WP-Cron cannot do: query one hook, count pending work, filter by status, and above all claim a batch of rows so two workers do not run the same job. The `claim_id` column and the separate `actionscheduler_claims` table are the concurrency control that the option array has no way to express. Nine indexes also means every insert writes nine index entries, and a store that queues a hundred thousand actions pays for that. It is a real trade, not a free upgrade. ### The args column has a spill Note `args varchar(191)` with `extended_args varchar(8000)` beside it. Arguments above the index-safe length spill into the second column, which is not indexed, so lookups by argument get slower for those rows. ## Action Scheduler does not take over. It rides on WP-Cron. This is the part that gets stated wrongly most often. In `classes/ActionScheduler_QueueRunner.php`, `WP_CRON_HOOK` is `action_scheduler_run_queue` and `WP_CRON_SCHEDULE` is `every_minute`. It calls `wp_schedule_event()` when nothing is scheduled, then hooks its own `run()` to that event. The `every_minute` schedule is one it adds itself. There is also an async dispatcher that fires a loopback request, but the reliable path is the cron event. ### If WP-Cron is broken, both are broken So if WP-Cron is broken on your site, Action Scheduler is broken too. Setting `DISABLE_WP_CRON` without a working system cron entry stalls both. The failure looks different, because Action Scheduler has an admin screen that shows a growing pending count while WP-Cron just quietly does nothing, but the cause is the same one I went through in [why posts miss their schedule](https://adityaarsharma.com/wordpress-missed-schedule-wp-cron/). Which scheduler runs a given job is decided by the plugin author, not by the site. WooCommerce puts its own recurring work into Action Scheduler. Core events like `wp_version_check`, `wp_scheduled_delete` and `delete_expired_transients` stay in the `cron` option. Both queues are live at once, and neither knows about the other. ## What the retention actually is From `classes/ActionScheduler_QueueCleaner.php` in 4.1.0. The class holds `$month_in_seconds = 2678400`, which is 31 days. The lifespan runs through `action_scheduler_retention_period`, and failed actions get three times that through `action_scheduler_retention_period_for_failed`. So: 31 days for completed and cancelled actions, 93 days for failed ones. The default purge list in the same file is only two statuses, `STATUS_COMPLETE` and `STATUS_CANCELED`. ### Nothing purges pending Nothing purges `pending`. A recurring action whose plugin was deactivated without cleanup leaves pending rows in the table permanently, and no retention setting will remove them. That is usually where a table with millions of rows came from. The 93 day failed-action retention was introduced in 4.0.0, released 16 June 2026, and the changelog lists it as a breaking change. The comment in the source explains the choice: three months to align with a quarterly accounting cycle, with a filter for stores that need a different period for PCI DSS reasons. ## The batch size of 20, and when it stopped losing the race This is the widely repeated criticism, and it needs a date attached to it, because it is no longer true on a current install. In Action Scheduler 3.9.2, `clean_actions()` took its `$batch_size` from a constructor default of 20 and passed it straight into `query_actions()` as `per_page`, once per status. ### Why twenty lost the race Twenty rows per status, per cleanup pass, and cleanup ran once at the start of a queue run. A store completing several hundred actions an hour deleted twenty completed and twenty cancelled per pass. The table grew faster than the cleaner emptied it, and it never caught up. That is the race, and it was real. Version 4.0.0 changed it. The same method in 4.1.0: `$is_scheduled_cleanup = doing_action( self::RUN_SCHEDULED_CLEANER_HOOK ) || doing_action( self::CONTINUE_SCHEDULED_CLEANER_HOOK ); // 250 balances replication safety, backlog clearance speed, and claim slot duration on high-volume stores. $iteration_batch_size = $is_scheduled_cleanup ? max( 250, $batch_size ) : $batch_size;` And at the end of the same method there is a continuation: if the pass was a scheduled cleanup and there is more to do, it calls `as_schedule_single_action()` on `CONTINUE_SCHEDULED_CLEANER_HOOK` straight away. If a pass fills its budget, it queues another pass immediately. Cleanup keeps going until the backlog is gone, rather than doing a fixed twenty and stopping. Cleanup also became its own daily action at 3am site time, from `register_recurring_actions()`, rather than something bolted onto the front of a queue run. The constructor default of 20 is still there. It applies to the inline cleanup path during a queue run, deliberately, so that path does not get heavier. ### Which version are you actually on That is the only question that matters here, and the answer is not the version on wordpress.org. WooCommerce bundles Action Scheduler rather than depending on the standalone plugin. WooCommerce 11.0.1, the current release on 3 September 2026 with 7,000,000 active installs according to the wordpress.org plugin API, pins `woocommerce/action-scheduler` at 4.0.0 in its `composer.json`. The standalone plugin is at 4.1.0. Both have the fix. An older bundled copy in some other plugin does not, and Action Scheduler resolves to the highest version present across every plugin that ships it. Check, do not assume. `wp eval 'echo ActionScheduler_Versions::instance()->latest_version();'` prints the version that actually resolved. Then run the status count: `wp db query "SELECT status, COUNT(*) AS n FROM $(wp db prefix --allow-root)actionscheduler_actions GROUP BY status ORDER BY n DESC;"` Group by `hook, status` instead to see which hook is responsible, and clear the safe statuses with `wp action-scheduler clean --before='31 days ago' --batch=500 --status=complete,canceled`. If the second query shows a large `pending` count against a hook you do not recognise, that is a deactivated plugin's leftovers and no retention setting will clear it. ![Bar chart of rows deleted per cleanup pass: 20 in Action Scheduler 3.9.2, 250 in 4.0.0 and later.](https://adityaarsharma.com/wp-content/uploads/2026/09/3707b883-d1f2-47c4-9ec3-26f62d5eb1c0_2912x1632-scaled.png)Built from the batch sizes in this post, read from the Action Scheduler source on 3 September 2026. ## Throughput, which is a separate limit Cleanup is not the only budget. From `ActionScheduler_QueueRunner.php` and the abstract runner it extends: - 25 actions per batch, filter `action_scheduler_queue_runner_batch_size`- 30 second time limit per batch, filter `action_scheduler_queue_runner_time_limit`- 1 concurrent batch, filter `action_scheduler_queue_runner_concurrent_batches`- memory ceiling at 90 percent of the PHP limit, defaulting to 128M when `memory_limit` is unreadable One batch of 25 per minute is a ceiling of 1,500 actions an hour on the WP-Cron path alone, before the async dispatcher adds anything. A store generating more than that will fall behind no matter how well the cleaner performs, and raising the retention period does not help. If the pending count grows steadily, that is a throughput problem, not a cleanup problem. ![Stat card: 1,500 actions an hour, the ceiling on the WP-Cron path alone.](https://adityaarsharma.com/wp-content/uploads/2026/09/cb54d513-f6b1-4e51-952e-35871ff384e7_2400x2400.png)Built from the batch size and schedule in this post, read from Action Scheduler 4.1.0 on 3 September 2026. ## What does not work **Truncating the tables.** It removes pending actions along with completed ones, including recurring actions that will not reschedule themselves because the row that would have triggered them is gone. Use `wp action-scheduler clean` with an explicit status filter. **Setting `action_scheduler_retention_period` to a very small number.** It reduces how long rows survive, not how fast they are deleted. If the cleaner is already behind, a shorter retention just makes the backlog it is chasing larger. **Moving core events to Action Scheduler.** There is no supported way to do it, and core's own events assume the WP-Cron API. Both queues stay. **Fixing the tables while ignoring the `cron` option.** The two are independent. A store can have a healthy actions table and a two megabyte autoloaded `cron` row from something else entirely. The other queries worth running on a store are in [the WooCommerce performance breakdown](https://adityaarsharma.com/woocommerce-performance-queries-that-cost-you/). ## The one thing to do Run the status count query above. If `pending` is larger than `complete`, you are behind on throughput and the cleaner is not your problem. If `complete` is enormous and the site is on a bundled Action Scheduler older than 4.0.0, you are on the wrong side of a fix that already shipped. MRK WP has a short practical walkthrough of clearing an oversized scheduled action log. It covers the admin screen and the table cleanup rather than the mechanism, so it pairs with the queries above rather than replacing them. https://www.youtube.com/watch?v=8o4zbZ6cHrQ How to clear large scheduled action logs in WordPress and WooCommerce, MRK WP on YouTube. ## Resources - [actionscheduler.org](https://actionscheduler.org/), the official documentation- [woocommerce/action-scheduler](https://github.com/woocommerce/action-scheduler) on GitHub, including the changelog quoted above- [WP-Cron](https://developer.wordpress.org/plugins/cron/) in the Plugin Handbook- [wp-includes/cron.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/cron.php) on wordpress-develop- [wp cron event](https://developer.wordpress.org/cli/commands/cron/event/) in the WP-CLI command reference- [WP Crontrol](https://wordpress.org/plugins/wp-crontrol/), for inspecting the cron option through the admin --- # What Actually Makes a WordPress Site Slow, Measured on a Real Site Source: https://adityaarsharma.com/what-actually-makes-a-wordpress-site-slow/ I ran this against my own homepage six times tonight, 2 September 2026: `curl -s -o /dev/null -D h.txt \ -w 'ttfb=%{time_starttransfer} connect=%{time_connect}\n' \ https://adityaarsharma.com/ grep -i 'x-runcloud-cache\|cf-cache-status' h.txt`Time to first byte came back at 0.687, 1.073, 1.004, 0.488, 1.030 and 1.572 seconds. Median around one second. Every one of those requests returned `x-runcloud-cache: HIT`. The page cache was working perfectly and the page still took a second to start arriving. That is the finding, and it is inconvenient because I am the one who configured this site. The slow part of my WordPress site is not WordPress. It is not PHP, not the database, not a plugin doing something stupid on `init`. I can prove that with one more command, and the proof takes about thirty seconds to run on your site too. ## Prove PHP is not the problem in one command A page cache HIT means PHP barely ran. But barely is not zero, so I removed WordPress from the path completely. I requested a plain static stylesheet, 5,829 bytes, with a random query string on the end so Cloudflare could not serve it from the edge and had to fetch it from my origin server. The loop was four `curl` runs against `hello-elementor/assets/css/reset.css` with `?bust=$RANDOM$RANDOM` on the end, printing `time_starttransfer` and the `cf-cache-status` header each time, with a two second sleep between them. Four runs, all `cf-cache-status: MISS`: 1.448s, 0.580s, 0.786s, 0.612s. A static file. No PHP, no MySQL, no plugins, no theme. Nginx opening a file and sending 5,829 bytes. It still took between half a second and one and a half seconds to return the first byte. ### The same file, served from the edge The same file served from the Cloudflare edge, on a `HIT`, came back in 0.225 seconds with a 0.065 second connect time. So the edge is 65 milliseconds away from me and the origin is roughly another 400 to 1,200 milliseconds beyond that. | What was served | Cache state | TTFB measured 2 Sep 2026 | | --------------- | ----------- | ------------------------ | | 5,829 B static CSS | Cloudflare edge HIT | 0.225 s | | 5,829 B static CSS | Cloudflare MISS, forced to origin | 0.580 to 1.448 s | | 170,466 B homepage HTML | WordPress page cache HIT at origin | 0.488 to 1.572 s | | Same homepage, per Lighthouse | Google datacentre, emulated network | 50 ms | ### Read the last two rows together Read the last two rows next to each other. Serving a 5.8 KB static file from my origin costs about the same as serving a 170 KB cached WordPress page from my origin. The variable is not what is being served. It is the trip. Which means the entire genre of advice I have been reading for a decade, install a caching plugin, switch to PHP 8.4, add an object cache, would have moved my TTFB by close to nothing. The work was already cached. The distance was the cost. ## Why Lighthouse says 50 ms and my terminal says 1,000 ms ![PageSpeed Insights report for adityaarsharma.com on mobile, 2 September 2026, showing performance 79, largest contentful paint 4.5 seconds, and No Data for real user experience.](https://adityaarsharma.com/wp-content/uploads/2026/09/45c02c78-6691-4904-a6ea-edd777df9d87_2800x2500-scaled.png)PageSpeed Insights on adityaarsharma.com, mobile, captured 2 September 2026 at 21:32 IST. Screenshot taken by me from pagespeed.web.dev. Note the No Data on field results and the 4.5 s LCP.Lighthouse reported *Root document took 50 ms* in the same run where my terminal measured roughly a second. Both numbers are correct. Lighthouse ran from a Google datacentre and applied simulated network throttling to the transfer, not to the round trip that reaches your origin. Its server response figure tells you how long your server took once the request arrived. It does not tell you how long the request took to get there. ![The web.dev Web Vitals article listing the Core Web Vitals metrics](https://adityaarsharma.com/wp-content/uploads/2026/09/3d22b251-8ee9-4f3e-b942-c14cbc151c72_2800x1720-scaled.png)The Web Vitals article on web.dev, read 3 September 2026. Lab tools and field data measure different things, which is the whole gap in this section. ### Why your PageSpeed score looks fine If you have ever looked at a green server response time in PageSpeed while your visitors complain the site is slow, that gap is the reason. The fix for it is not on your server. It is either an edge that caches your HTML, or an origin closer to your readers. ### The actual defect: DYNAMIC on every HTML request On my site Cloudflare is in front of the domain and returns `cf-cache-status: DYNAMIC` on every HTML request, which means it caches none of my HTML and passes every page view through to origin. That is the actual defect. I wrote about the layers and how to tell which one is failing in a companion piece, and the short version is that a page cache and a CDN cache are different caches and only one of mine is doing anything. ## Then I counted the bytes, and it was not WordPress either Second measurement. Every stylesheet and script the homepage requests, fetched twice, once with `Accept-Encoding: identity` and once with gzip, so I get both the real file size and what actually crosses the wire. It counts `<link rel=stylesheet>` and `<script src=>` in the saved HTML, then fetches each file twice with `-H 'Accept-Encoding: gzip'` and `-H 'Accept-Encoding: identity'`, printing `size_download` both times. 27 stylesheets. 41 script tags, of which 16 turned out to be inline scripts that a plugin had rewritten into `data:text/javascript;base64` URIs, leaving 25 real network requests. 52 files on the render path. Total 1,367,827 bytes uncompressed, 375,551 bytes gzipped. ### The largest file on the page belongs to Google Here is the part I did not expect. Sorted by gzipped weight, the largest single file on my WordPress homepage belongs to Google: | File | Uncompressed | Gzipped | Share of all CSS and JS | | ---- | ------------ | ------- | ----------------------- | | googletagmanager.com/gtag/js | 520,085 B | 174,628 B | 46.5% | | platform.twitter.com/widgets.js | 93,967 B | 27,731 B | 7.4% | | jquery 3.7.1 (WordPress core) | 87,553 B | 30,272 B | 8.1% | | Elementor 4.2.2 frontend.min.js | 32,098 B | 10,399 B | 2.8% | | Elementor 4.2.2 frontend.min.css | 54,666 B | 7,212 B | 1.9% | Four third-party files carry 205,436 of the 375,551 gzipped bytes. That is 54.7 percent of the CSS and JavaScript weight of a WordPress site, and none of it is WordPress, the theme, or a page builder. The Google Analytics tag alone outweighs jQuery, Elementor's stylesheet and Elementor's script combined, by a factor of three. ### What I am and am not telling you to do I am not going to tell you to remove analytics. I am going to tell you that if you spent last weekend shaving 8 KB off a theme stylesheet while a 174 KB analytics tag sat two lines above it in the `head`, you were optimising the wrong file. Measure before you cut. That is the entire point of this post. ## Three bugs the measurement found on my own site Running your own numbers has a side effect. It shows you things you would never have looked for. ### Bug one: Plausible loaded twice **One. Plausible was loaded twice.** Two byte-identical script tags in the same document. Same `data-domain`, same `defer`, same `fetchpriority="low"`. That was 1,321 gzipped bytes wasted and, more to the point, a second network connection for no reason. The cause took longer to find than the symptom. Perfmatters was injecting the tag through its header code setting, and an Elementor Pro custom code snippet, post 2322, was injecting the same tag on `elementor_head`. Two plugins, two different injection points, one duplicate. That is the lesson worth keeping: a duplicated asset is rarely one plugin misbehaving, it is two systems that each believe they own the tag. I set the snippet to draft. Counting `analytics.adityaarsharma.com/js/script.js` in the live HTML now returns 1, checked 3 September 2026. ### Bug two: a stylesheet with nothing in it **Two. A stylesheet that contains nothing.** One of the minification cache files returned a valid HTTP 200 with `content-length: 0` and a last-modified date of 26 August. I published that as a minifier bug. That was wrong, and the real cause is a better story. The child theme enqueued `hello-elementor/style.css` as `parent-style`. That file is a header comment and nothing else. It contains zero CSS rules. So the minifier did exactly the right thing. Given a source file with no rules in it, a zero byte output is correct. The file was empty because the source was empty. There was a second identical case, `20d77e3c2655.style.min.css`, from the same enqueue. I removed the enqueue and both URLs now return 404, checked 3 September 2026. Nothing in any dashboard would ever have told me this. Only counting the files did. ### Bug three: brotli losing to gzip **Three. Brotli is losing to gzip.** Same HTML, four encodings, requested back to back: | Accept-Encoding | Bytes returned | | --------------- | -------------- | | identity | 170,466 | | gzip | 33,075 | | br | 33,392 | | zstd | 33,427 | Brotli returned 317 more bytes than gzip. That is not a broken CDN, it is compression level. Brotli at a high level beats gzip comfortably, but an edge compressing dynamic responses on the fly uses a low level to keep latency down, and at low levels the two are a coin toss. Worth knowing before you read a vendor page claiming brotli saves you 20 percent. ## The DOM, where a page builder actually costs you Last measurement, and this one needs a real browser because the count that matters is the rendered tree, not the HTML source. Open the page, then run this in the console: `let max = 0; const walk = (el, d) => { if (d > max) max = d; for (const c of el.children) walk(c, d + 1); }; walk(document.body, 0); ({ bodyElements: document.body.getElementsByTagName('*').length, divs: document.querySelectorAll('div').length, maxDepth: max });`My homepage: 879 elements in the body, 522 of them `div`, maximum nesting depth 17. ![Chrome Lighthouse DOM size documentation stating it warns above about 800 body nodes and errors above about 1400](https://adityaarsharma.com/wp-content/uploads/2026/09/bbde9cac-cdaf-48e2-af00-293d0fdf9c00_2800x1720-scaled.png)The Lighthouse DOM size audit on Chrome for Developers, read 3 September 2026. Warns above roughly 800 body nodes, errors above roughly 1,400. ### What Chrome says the limits are Chrome's Lighthouse DOM size documentation, last updated 21 June 2024, says the audit warns when the body has more than about 800 nodes and errors above about 1,400. So my own homepage sits just over the warning line, at 879. Sixty percent of that tree is `div` elements produced by a page builder to hold layout. ### Depth is the cost people miss Depth is the cost that people miss. A deep tree makes the browser recompute style and position down the whole chain whenever anything changes, which shows up as sluggish interaction rather than slow loading. It lands on Interaction to Next Paint, not on load time, which is why a site can score well on paint metrics and still feel bad to use. ## What this does not tell you ![Chrome UX Report overview page explaining an origin needs enough visitors for a statistically significant dataset](https://adityaarsharma.com/wp-content/uploads/2026/09/d43e50c1-101d-49e3-836e-39c1214473cb_2800x1720-scaled.png)The CrUX overview on Chrome for Developers, read 3 September 2026. An origin needs enough visitors to be represented at all, which is why this URL shows No Data.- **These are single measurements from one location.** I ran each one between four and six times and reported the spread, but everything here is from one machine in India hitting a Cloudflare edge in Singapore. Your distances are different and so are your numbers.- **None of this is field data.** PageSpeed showed No Data under real user experience for this URL, because the site does not have enough Chrome traffic to appear in the Chrome User Experience Report. Everything above is a lab measurement or a byte count, and I have labelled it as such.- **Two of the three bugs are fixed, the big one is not.** The duplicate Plausible tag and the empty stylesheet are both gone, verified on 3 September 2026. Cloudflare still returns `cf-cache-status: DYNAMIC` on every HTML request, so the HTML is still not edge cached and that is still the defect that costs the most. ## Do this in the next ten minutes Pick your own homepage and run these, in this order. They take under a minute and they will tell you which of the four possible problems you actually have. `# 1. Is your page cache even hitting, and how slow is a hit? curl -s -o /dev/null -D h.txt -w 'ttfb=%{time_starttransfer}\n' https://YOURSITE/ grep -iE 'x-.*cache|cf-cache-status|age:' h.txt # 2. Is the distance the cost? Force origin with a cache-busting query string curl -s -o /dev/null -w 'ttfb=%{time_starttransfer}\n' \ 'https://YOURSITE/wp-includes/js/jquery/jquery.min.js?bust=1'`Then step 3, the byte count. Save the page with `curl -s https://YOURSITE/ -o p.html`, count stylesheets and scripts with `grep -coE '<link[^>]+rel=.stylesheet' p.html` and `grep -coE '<script[^>]+src=' p.html`, then fetch the page once with `-H 'Accept-Encoding: gzip'` and print `size_download`. If step 1 shows a cache HIT and step 2 is still slow, your problem is distance and no plugin will fix it. If step 1 shows a MISS every time, your page cache is broken and that is the first thing to repair. If both look fine and the site still feels slow, the answer is in step 3 and in the DOM count above. The one number I would put on a wall: on my site, the page cache was working, and the page still took a second. Check yours before you buy anything. ## Related reading - The same habit applied to malware scanning, where the tool said clean and the raw response said otherwise: [the scanner said clean, the site was serving spam to Googlebot](https://adityaarsharma.com/the-scanner-said-clean-the-site-was-serving-spam-to-googlebot/).- Images are the other half of byte weight, and there is a free way to deal with them in bulk: [how to compress WordPress images in bulk](https://adityaarsharma.com/how-to-compress-wordpress-images-in-bulk/).- Where those 27 stylesheets come from when you build with Elementor, and whether a child theme changes it: [do you need a child theme with Elementor](https://adityaarsharma.com/do-you-need-a-child-theme-with-elementor/).- If a media query is not applying, the cause is often the same stylesheet ordering that produces this file count: [how to fix media query not working in WordPress](https://adityaarsharma.com/how-to-fix-media-query-not-working-in-wordpress/).- For running these checks on a schedule instead of by hand: [running Claude Code against WordPress](https://adityaarsharma.com/running-claude-code-against-wordpress-the-complete-setup/). ## Resources - [Lighthouse DOM size audit](https://developer.chrome.com/docs/lighthouse/performance/dom-size), Chrome for Developers, last updated 21 June 2024. Source of the 800 and 1,400 node thresholds.- [Web Vitals](https://web.dev/articles/vitals), web.dev, last updated 31 October 2024.- [PageSpeed Insights](https://pagespeed.web.dev/). The run quoted here used Lighthouse 13.4.1 on an emulated Moto G Power.- [curl `--write-out` variables](https://curl.se/docs/manpage.html#-w), for the timing fields used in every command above.- [Chrome User Experience Report](https://developer.chrome.com/docs/crux), the field dataset that returned No Data for this URL. ## More on WordPress performance - [A CDN for WordPress: What Actually Helps, and What Just Moves the Problem](https://adityaarsharma.com/cdn-for-wordpress-what-actually-helps/)- [Finding the Slow Query in WordPress, Without Guessing](https://adityaarsharma.com/finding-the-slow-query-in-wordpress/)- [What Web Fonts Actually Cost on WordPress, Measured With curl](https://adityaarsharma.com/wordpress-fonts-what-they-actually-cost/)- [WordPress Image Formats in 2026: What Core Does, and Bytes I Measured Myself](https://adityaarsharma.com/wordpress-image-formats-webp-avif-2026/) --- # What WordPress Core Actually Does When It Lazy Loads Your Images Source: https://adityaarsharma.com/wordpress-lazy-loading-what-core-does/ I counted the loading attributes on one of my own posts and got zero `loading="lazy"` and six `fetchpriority="high"`. Core has been adding the lazy attribute automatically since WordPress 5.5, and it marks exactly one image as the high-priority one. So neither number came from core. I turned off the plugin settings responsible and counted again on 3 September 2026: ![Core marks one image high priority. My site marked six.](https://adityaarsharma.com/wp-content/uploads/2026/09/9c54523a-60bc-46ae-92bb-5ce9e05d9b98_2912x1632-scaled.png)The first count, before the fix. After turning off the plugin settings named later in this post, the same page returns two.`curl -s --compressed https://adityaarsharma.com/how-to-compress-wordpress-images-in-bulk/ -o post.html grep -o '<img' post.html | wc -l # 5 grep -o 'loading="lazy"' post.html | wc -l # 2 grep -o 'fetchpriority="high"' post.html | wc -l # 2 grep -o 'data-src=' post.html | wc -l # 5`Two images marked as the highest priority resource on the page. Core will only ever mark one, so one of those two still comes from somewhere else and I have not found it yet. The five `data-src` attributes are not what I first assumed. Every one of them sits beside a real, working `src`. Those images load with JavaScript disabled. They come from WP Compress adaptive images. I am saying that plainly because I nearly published the opposite. A `data-src` count on its own tells you nothing about whether an image is gated on JavaScript. You have to check whether a real `src` is there too. That is worth knowing before you read the rest, because the function I am about to walk through is the one most WordPress sites never actually run. It is good, it is careful, it handles cases nobody thinks about, and a single optimisation plugin can throw all of it away without saying so. ## The function that makes the decision Everything lives in `wp_get_loading_optimization_attributes()`, in `wp-includes/media.php`. I downloaded WordPress 7.1 from wordpress.org this morning, 3 September 2026, and read it there. The version string in `wp-includes/version.php` confirmed 7.1, and every line quoted below is from `wp-includes/media.php` in that tarball rather than from a blog post about it. ![WordPress reference page for wp_get_loading_optimization_attributes listing the loading, fetchpriority and decoding attributes it returns](https://adityaarsharma.com/wp-content/uploads/2026/09/1d8bf6a1-fb39-44c8-9d28-58643b64c045_2800x1720-scaled.png)The function reference on developer.wordpress.org, read 3 September 2026. Three attributes, one function, and it will not set two of them at once.It takes three arguments: the tag name, the attribute array, and a context string. It returns an array of attributes to merge into the tag, which in practice is some combination of `decoding`, `loading` and `fetchpriority`. It decides both halves of the problem in one place, which matters, because lazy loading and priority hinting are the same decision seen from two ends. ## The rules, in the order core applies them **1. Only img and iframe.** The comment in core says that for now the function only supports images and iframes. Every other tag returns straight away. ### Block templates are skipped **2. Block templates are skipped.** Context `'template'` returns an empty array before anything else runs, with the comment that it is handled more granularly elsewhere, and the skip applies to `fetchpriority` too. ### Images inside a content blob wait for the outer pass **3. Images inside a content blob are deferred to the outer pass.** This one is subtle and it is the reason a lot of homegrown code produces wrong results. If the function is called while `the_content`, `widget_text_content` or `widget_block_content` is running, but with a different context, it returns nothing. The guard is three `doing_filter()` checks. If `the_content`, `widget_text_content` or `widget_block_content` is running and the context passed in is not that same filter, core hands the decision back to the outer pass. The comment in core explains why: an image generated programmatically inside post content would otherwise be counted in its own context, which skews the media count, and that can result in the first content image being lazy-loaded or an image further down the page being marked high priority. Both of those are the failure this whole system exists to prevent. ### decoding async goes on everything **4. decoding="async" goes on every image, unconditionally.** The line is `$loading_attrs['decoding'] = $attr['decoding'] ?? 'async';`, and it runs before any of the viewport logic. ### No width and height means no decision at all **5. No width and height means no decision at all.** This is the rule I would put on a wall: `// For any resources, width and height must be provided, to avoid layout shifts. if ( ! isset( $attr['width'], $attr['height'] ) ) { return apply_filters( ... ); }`An image with no intrinsic dimensions gets `decoding="async"` and nothing else. No `loading`, no `fetchpriority`. If your theme prints a hero image with a hand-written `<img>` tag and no width or height, core silently declines to prioritise it, and you get neither of the two things you wanted. It also gives you a layout shift, which is the other half of the problem I went through in [the Core Web Vitals thresholds piece](https://adityaarsharma.com/core-web-vitals-wordpress-2026/). ## The skip-first-N heuristic, and why the number is three Core keeps a counter of media elements it has already seen in the main loop. The threshold below which it omits the `loading` attribute is set by `wp_omit_loading_attr_threshold()`, and the docblock records its history. The docblock is explicit. `$omit_threshold` is the number of media elements where the `loading` attribute will not be added, it has existed since 5.9.0, and the default changed from 1 to 3 in 6.3.0. The line is `apply_filters( 'wp_omit_loading_attr_threshold', 3 )`. The counter itself is a static variable in a function, and reading it means calling that function with a zero increment, which core does explicitly. Core calls `wp_increase_content_media_count( 0 )` to read the counter without moving it, then sets `$maybe_in_viewport` to true while that count is below `wp_omit_loading_attr_threshold()` and false once it is not. So the first three media elements in the main loop are treated as in-viewport and never get `loading="lazy"`. Everything after is lazy. That is the whole heuristic, and it is a heuristic rather than a measurement: core has no idea where your viewport ends, so it guesses that the first three things are probably visible. ![WordPress reference page for the wp_omit_loading_attr_threshold filter showing Default 3](https://adityaarsharma.com/wp-content/uploads/2026/09/71595feb-2722-4a69-be4a-71031c0da470_2800x1720-scaled.png)The `wp_omit_loading_attr_threshold` reference, read 3 September 2026. Default 3, and the source line is in wp-includes/media.php. ### Why the default went from 1 to 3 The reason the default went from 1 to 3 in WordPress 6.3 is the mismatch between document order and visual order. A layout with a small logo, an author avatar and then the hero image puts the actual LCP element third. With a threshold of 1, the hero got `loading="lazy"`, which is precisely the own goal. Three is a compromise that covers most themes and over-eagerly loads two extra images on the rest. ## The other half: fetchpriority, and the 50,000 pixel gate Exactly one image per page gets `fetchpriority="high"`, and it has to be big enough to plausibly be the LCP element. The logic is in `wp_maybe_add_fetchpriority_high_attr()`. Three checks run in order. Lazy and high priority are mutually exclusive, so an image already marked `loading="lazy"` returns immediately. Then `wp_high_priority_element_flag()` has to still be true. Then `apply_filters( 'wp_min_priority_img_pixels', 50000 )` is compared against width times height, and only then is `fetchpriority` set to high and the flag burned. ![WordPress reference page for wp_min_priority_img_pixels showing a minimum square pixels default of 50000](https://adityaarsharma.com/wp-content/uploads/2026/09/907b6b2b-1fb7-4112-bb20-257ed0313694_2800x1720-scaled.png)The `wp_min_priority_img_pixels` reference, read 3 September 2026. Minimum square-pixels threshold, default 50000. ### Three checks, in that order Three things are happening. `wp_high_priority_element_flag()` is a one-shot latch: once an image claims high priority, the flag is set false and no other image on the page can get it. The 50,000 square pixel floor is an area test, so a 260 by 200 image passes at 52,000 and a 200 by 200 avatar fails at 40,000. And the mutual exclusion with lazy is enforced in code rather than left to a developer's discipline. ### Core will shout at you if you do both Core will even shout at you if you try to do both. If an image arrives already carrying `fetchpriority="high"` and core has determined it is not in the viewport, it calls `_doing_it_wrong()` with the message that an image should not be lazy-loaded and marked as high priority at the same time. It still honours your attribute afterwards, with a comment saying it should not override what a developer decided even though it seems incorrect. That is a good instinct in a framework and a bad outcome on a page. ## The three fetchpriority values you did not know core reads Newer core does not only write `fetchpriority`, it reads it as a signal from blocks. All three values change the decision: - `high` already present means the developer picked the LCP element. Core honours it and burns the latch.- `low` means the image is present but not initially displayed. Core's comment names the cases: hidden in the Navigation Overlay, occluded in a non-initial carousel slide, a collapsed Details block. Such images are marked not-in-viewport but are deliberately *not* lazy-loaded, because the browser has no heuristic for when to start loading something it cannot see.- `auto` means block visibility support decided the block is conditionally displayed by viewport size. Core preserves it, refuses to give it high priority, and skips the media count increment entirely so that a run of viewport-conditional images does not push the real hero past the threshold.That `low` case is the one worth internalising. There is a difference between an image that is below the fold and an image that is hidden. Lazy loading is correct for the first and actively wrong for the second, because a lazy image inside a closed accordion will not begin downloading until the user opens it, and then they wait. ## Where the classic own goal actually comes from The advice everyone gives is do not lazy load your LCP image. Nobody explains how it happens, so here are the four routes I can name from the source above. ### Four routes to a lazy-loaded LCP image **The image is not in the main loop.** The counting path only runs when `! is_admin() && in_the_loop() && is_main_query()`. There is a second path for images before the loop, guarded by `$wp_query->before_loop`, `did_action( 'get_header' )` and `! did_action( 'get_footer' )`, plus a filterable list of header contexts. A page builder that renders the hero outside all of those falls into the final branch where `$maybe_in_viewport` is still null, and null is treated as not-in-viewport, which means lazy. **The hero is fourth.** Three decorative images ahead of it in document order and the threshold is spent. **No width and height.** Covered above. Core declines to decide and you get nothing. **A plugin added the attribute globally.** An optimisation plugin that regex-replaces every `<img` with a lazy version does not consult any of this logic. That is what happened on my own site. It produced six high-priority images on one post, which means none of them were prioritised in any useful sense, because the browser had six equally urgent things to fetch. Two plugins were each preloading the same image. Turning off Perfmatters lazy loading, turning off WP Compress `fetchpriority-high` and `optimize-lcp`, and clearing the Perfmatters critical image preload brought it down to two. ## The four filters, and which one to actually use In increasing order of bluntness: - `add_filter( 'wp_omit_loading_attr_threshold', fn() => 1 );` raises or lowers how many leading images stay eager. Cheap and safe.- `add_filter( 'wp_min_priority_img_pixels', fn() => 20000 );` changes the area floor for the high-priority image.- `wp_lazy_loading_enabled` receives `$default`, `$tag_name` and `$context`, so you can return `false` for one tag in one context, for example `wp_get_attachment_image`.- `pre_wp_get_loading_optimization_attributes` short-circuits the whole function. Return an array such as `array( 'decoding' => 'async', 'fetchpriority' => 'high' )` and core does nothing else for that element. ### Which filter to reach for Filter 1 is the one to reach for. If your theme's LCP element is genuinely the first image in the loop, dropping the threshold back to 1 loads two fewer images eagerly and costs you nothing. If your theme puts three small things ahead of the hero, fix the theme rather than the threshold. Filter 4 exists for the case where you know the answer and core cannot. It is a short circuit, so whatever array you return is final for that element. Use it in a template with an explicit context string, never globally. ## How to check your own site in one command Fetch a real page and count. Not a plugin dashboard, the actual HTML that reaches a browser: `curl -s --compressed https://YOURSITE.com/some-post/ -o p.html grep -o '<img' p.html | wc -l grep -o 'loading="lazy"' p.html | wc -l grep -o 'fetchpriority="high"' p.html | wc -l grep -o 'data-src=' p.html | wc -l`Then count the images with no width attribute, with `grep -o '<img[^>]*>' p.html | grep -vc 'width='`. What you want to see: exactly one `fetchpriority="high"`, zero images missing a width attribute, and `loading="lazy"` on everything except the first few. More than one high-priority image means something is overriding core's latch. That is the number to chase first. A non-zero `data-src` count is a prompt to look closer rather than a verdict. If the same tag also carries a real `src`, the image loads without JavaScript and the `data-src` is an adaptive-image hook. If there is no `src` at all, then the image really does wait for your JavaScript bundle to parse and execute before it starts downloading. That last one is the real cost and it does not show in a byte count. I went through the same class of problem measuring my whole homepage in [the post about what actually makes a WordPress site slow](https://adityaarsharma.com/what-actually-makes-a-wordpress-site-slow/), and the pattern repeats: the plugin is not adding weight, it is adding a dependency between two things that had no reason to depend on each other. The block asset pipeline has its own version of this, which I covered in [the block.json asset loading post](https://adityaarsharma.com/block-json-asset-loading/). ## One thing to do next Run the count above on your busiest post. If `fetchpriority="high"` comes back as anything other than 1, find the plugin doing it and turn off its image handling, then run it again. Mine came back as 2 after I did exactly that, so I am not finished either. Core's version of this is better than almost every plugin's, and you already have it. If you are also compressing on the way in, [the bulk image compression walkthrough](https://adityaarsharma.com/how-to-compress-wordpress-images-in-bulk/) covers the upload side, and [the Elementor performance measurements](https://adityaarsharma.com/elementor-performance-what-actually-costs-you/) cover what a builder adds on top. ## Resources - [wp_get_loading_optimization_attributes()](https://developer.wordpress.org/reference/functions/wp_get_loading_optimization_attributes/) on developer.wordpress.org, the function this post reads.- [wp_omit_loading_attr_threshold](https://developer.wordpress.org/reference/hooks/wp_omit_loading_attr_threshold/), the filter for how many leading images stay eager. Default 3 since 6.3.0.- [wp_min_priority_img_pixels](https://developer.wordpress.org/reference/hooks/wp_min_priority_img_pixels/), the 50,000 square pixel floor for the high-priority image.- [wordpress.org/latest.tar.gz](https://wordpress.org/latest.tar.gz). Read the source rather than a summary of it; the file is wp-includes/media.php.- [WHATWG HTML: the img loading attribute](https://html.spec.whatwg.org/multipage/embedded-content.html#attr-img-loading), linked from core's own comment in wp_lazy_loading_enabled(). --- # Custom Code in Elementor Without a Child Theme: What Survives an Update and What Does Not Source: https://adityaarsharma.com/elementor-custom-code-without-child-theme/ This is the line that eats people's custom code. In WordPress 7.1, `Theme_Upgrader::upgrade()` adds `delete_old_theme` to the `upgrader_clear_destination` filter and calls `run()` with `clear_destination` set to true. ![A WordPress theme update does not merge. It deletes the theme directory, then copies the new one in.](https://adityaarsharma.com/wp-content/uploads/2026/09/897316cc-92cd-4308-8b4c-89bfda607f19_2912x1632-scaled.png) The theme directory is deleted, then the new one is copied in. Not merged. Deleted. `Plugin_Upgrader::upgrade()` does the same thing with `delete_old_plugin`. Whatever you typed into a parent theme's `functions.php` is gone the next time that theme updates, and there is no warning. The standard answer is a child theme. That is one option out of five, and for most of what people actually put in `functions.php` on an Elementor site, it is not the best one. Here is what each location really stores, and what survives what. ## The five places, and what each one survives | Where you put it | Actually stored in | Survives theme update | Survives theme switch | Survives plugin update | | ---------------- | ------------------ | --------------------- | --------------------- | ---------------------- | | Parent theme `functions.php` | a file in the theme directory | **no** | no | yes | | Child theme `functions.php` | a file in the child theme directory | yes | no | yes | | A must-use plugin | `wp-content/mu-plugins/*.php` | yes | yes | yes | | Customizer Additional CSS | a `custom_css` post keyed to the theme slug | yes | **no** | yes | | Elementor Custom CSS or Custom Code | post meta and a custom post type | yes | yes | yes | Two rows in that table surprise people, so both are worth the mechanism. ### Customizer Additional CSS is keyed to the theme, not to the site `wp_get_custom_css_post()` in `wp-includes/theme.php` queries for a single post of type `custom_css` whose `name` is `sanitize_title( $stylesheet )`, across every post status. So it is a real post whose `post_name` is the sanitised stylesheet directory name of the active theme. Update the theme and the post is untouched, because it lives in the database rather than the theme folder. Switch the theme and the CSS is still in your database and no longer loaded, because the lookup now asks for a different `post_name`. This catches people at exactly the wrong moment. Activating a child theme is a theme switch. Every rule you put in Additional CSS while the parent was active stops applying the second you activate the child. Nothing was deleted, and every diagnostic you run will say the CSS is fine. ### Must-use plugins are the location with no update path at all `wp-settings.php` line 505 loops `wp_get_mu_plugins()` and `include_once`s every file it returns. That is the whole loading mechanism. `wp_get_mu_plugins()` in `wp-includes/load.php` is deliberately simple. It reads the directory with `readdir()`, keeps anything ending in `.php`, and sorts the result. Read what that does and does not do. It reads the top level of `wp-content/mu-plugins` only, so **a file inside a subdirectory is never loaded**. It takes `.php` files only. It sorts alphabetically, which is your only control over load order. It runs before regular plugins. And no updater in WordPress ever writes to that directory, which is the whole point: there is no code path that can delete your file. The tradeoffs are real. A must-use plugin has no activation hook, cannot be deactivated from the admin, and does not appear in the normal plugins list. That last one is a feature when a client is the person clicking Deactivate, and a problem when you are debugging a white screen at midnight. Name the file so future-you finds it: `00-site-fixes.php` rather than `custom.php`. ## What Elementor actually stores where ### Custom CSS, at three levels, all in the database Elementor Pro puts Custom CSS at three levels, and all three land in the database rather than in a file you maintain: | Level | Where you enter it | Stored in | | ----- | ------------------ | --------- | | Site | Site Settings, Custom CSS tab | the kit document's settings, compiled into `post-<kitID>.css` | | Page | Page Settings, Advanced | `_elementor_page_settings` post meta | | Element | the element's Advanced tab | inside the element's node in `_elementor_data` | The meta keys are declared in `core/base/document.php` as `PAGE_META_KEY = '_elementor_page_settings'` and `ELEMENTOR_DATA_META_KEY = '_elementor_data'`. All of it compiles into the generated stylesheets under `wp-content/uploads/elementor/css/`, which means clearing Elementor's cache deletes the compiled output and never the source. If your custom CSS disappeared after a cache clear, it did not. The file just has not been rebuilt yet, and loading the page in the editor once rebuilds it. ### Custom Code is a post type, not a file Elementor's Custom Code feature is a separate thing again: a post type with its own publish state, scheduling and display conditions, holding HTML, JavaScript and CSS to be injected at `<head>`, body start or body end, with a priority from 1 to 10 where lower runs earlier. The documentation is unambiguous about its one hard limit: > Custom code does not support PHP snippets. You cannot use this feature to add custom hooks or actions. You can use a different approach for PHP, either through functions.php or a third-party plugin. > > Elementor, Add custom code, elementor.com/help/custom-code-pro/, last updated 14 January 2024 ![Elementor documentation page for the Custom Code feature, which states that custom code does not support PHP snippets.](https://adityaarsharma.com/wp-content/uploads/2026/09/6dad32c4-0c61-4b69-b1f9-ebe90d98f05a_2800x2100-scaled.png)elementor.com/help/custom-code-pro/, screenshot taken 3 September 2026. So the split is clean. Anything that is markup, script or style can live in Elementor and travels with the database. Anything that is a WordPress hook cannot, and needs one of the file-based locations above. ![Elementor documentation page Use selector in the custom CSS tab, last updated January 14 2024.](https://adityaarsharma.com/wp-content/uploads/2026/09/8c40871f-9d68-464a-b809-90a5c0663129_2800x2100-scaled.png)elementor.com/help/how-to-use-selector-in-the-custom-css-tab/, screenshot taken 3 September 2026. The Last Update line reads January 14, 2024. ## The selector keyword, and why your CSS targets the wrong element Elementor's element-level Custom CSS gives you a `selector` keyword. It does not mean the widget. From Elementor's own documentation on it, last updated 14 January 2024, with an Image widget selected: `selector { border: 5px solid red; } /* borders the wrapper, not the image */ selector img { border: 5px solid red; } /* borders the image */` ### selector is the wrapper, not your content This is the single most common reason a rule looks correct and does nothing visible. `selector` resolves to the element's wrapper. Your content sits inside it. The same documentation notes that if you use your own class instead of `selector`, you often need `!important` to beat Elementor's generated rule, which is a specificity fight you can avoid entirely by loading your CSS after Elementor's. ### The two hooks that decide the cascade From `includes/frontend.php` in the Elementor source, three actions fire in this order: - `elementor/frontend/before_enqueue_styles`- `elementor/frontend/after_enqueue_styles`, after elementor-icons and elementor-frontend are enqueued- `elementor/frontend/after_enqueue_post_styles`, after `post-<ID>.css` for the current document Enqueue on `elementor/frontend/after_enqueue_styles` and you land after Elementor's base stylesheet but before the per-post generated CSS. Enqueue on `elementor/frontend/after_enqueue_post_styles` and you land after both, which is where you want to be if you are overriding values a client set in the editor. Correct source order beats `!important` every time, and it is the reason [a media query in WordPress refuses to take effect](https://adityaarsharma.com/how-to-fix-media-query-not-working-in-wordpress/) more often than any breakpoint mistake. ## The setup I would actually ship One file, in `wp-content/mu-plugins/`, holding both the PHP that Elementor cannot host and the enqueue for a stylesheet you own. It survives every theme update, every plugin update, and a theme switch. `add_action( 'elementor/frontend/after_enqueue_post_styles', function () { $file = WP_CONTENT_DIR . '/site-custom/site.css'; if ( ! file_exists( $file ) ) { return; } wp_enqueue_style( 'site-custom', content_url( '/site-custom/site.css' ), array(), filemtime( $file ) ); } );` That is the whole file. Give it a normal plugin header so it shows up in the Must-Use list. Add a second `wp_enqueue_scripts` callback at priority 999 for pages Elementor does not render, guarded with `did_action()` so it never enqueues twice. ### Three details in that file that matter `filemtime()` as the version argument means the browser refetches the moment you edit the file and never otherwise, which removes the entire class of I-cleared-the-cache-and-it-still-shows-the-old-CSS problem. The stylesheet lives in `wp-content/site-custom/` rather than in a theme, so it is not in anything's delete path. And the `did_action()` guard stops the file being enqueued twice on a page that Elementor does render. PHP hooks go in the same file. A settings link after install, a search placeholder change, an email template condition: all of them are five to twenty lines that belong here rather than in a theme. The pattern in [adding a Go to Settings link after installing a WordPress plugin](https://adityaarsharma.com/go-to-plugin-settings-link-after-install/) and in [changing the WordPress search placeholder without a plugin](https://adityaarsharma.com/how-to-change-search-placeholder-text-in-wordpress/) both drop straight into a must-use plugin unchanged. ## What actually breaks on an Elementor update, and why ### What breaks is CSS written against internal markup Not your PHP. Elementor's PHP hooks are stable and deprecations are announced. What breaks is CSS written against Elementor's internal markup, because that markup is versioned and it has been shrinking on purpose. | Removed in | Class you can no longer select | | ---------- | ------------------------------ | | Elementor 3.0 | `.elementor-inner`, `.elementor-row`, `.elementor-column-wrap` | | Elementor 3.2 | `.elementor-image`, `.elementor-text-editor` | | Elementor 3.6 | `elementor-section-wrap` | | Elementor 3.19 | all of the above stopped being optional and became core behaviour | Source: Elementor's Optimized DOM Output documentation, last updated 1 February 2024. There is a second wave coming from the `e_optimized_markup` feature, whose description in the source says plainly that it includes markup changes so it might require updating custom CSS or JS and cause compatibility issues with third-party plugins. It is stable, it is off by default on existing sites, and it is on by default for sites installed on 3.30.0 or later. Which means a site you built last year and a site you build this month behave differently out of the box. ### Find your own exposure in one command Do it before an update, not after: `grep -rn --include='*.css' --include='*.php' --include='*.js' \ -e 'elementor-inner' \ -e 'elementor-row' \ -e 'elementor-column-wrap' \ -e 'elementor-section-wrap' \ -e 'elementor-text-editor' \ wp-content/themes wp-content/mu-plugins wp-content/site-custom 2>/dev/null` Anything that returns is already broken or one release away from it. Rewrite those selectors against a CSS class you set yourself on the element, in the Advanced tab, so the selector is yours and no Elementor release can remove it. That is also the durable way to handle things like [custom link colours in Elementor](https://adityaarsharma.com/how-to-easily-add-custom-link-colors-in-elementor/), where the tempting shortcut is to select a generated class. ![Stat card: five Elementor releases removed a class that custom CSS may be selecting.](https://adityaarsharma.com/wp-content/uploads/2026/09/c3f471b5-71ba-437c-9376-09a2ba37b093_2400x2400.png)Built from the removal table in this post. Source: Elementor Optimized DOM Output documentation, last updated 1 February 2024. ## So do you need a child theme For custom code on an Elementor site, mostly no. A child theme protects files in a theme directory, and on an Elementor build there is usually nothing in the theme directory worth protecting, because Theme Builder has replaced the template files. A must-use plugin covers the PHP with strictly stronger guarantees, since it also survives a theme switch. ### When a child theme still earns its place A child theme still earns its place when you are genuinely overriding template files, changing theme supports, or working on a classic theme you intend to keep. That decision is its own question, and worth reading properly in [whether you need a child theme with Elementor](https://adityaarsharma.com/do-you-need-a-child-theme-with-elementor/), [how a child theme differs from a normal WordPress theme](https://adityaarsharma.com/child-theme-vs-normal-wordpress-theme/), and [whether to create a child theme manually or with a plugin](https://adityaarsharma.com/wordpress-child-theme-manual-creation-vs-plugin/). Run the grep above against your current project. If it returns nothing, move your snippets out of `functions.php` into a must-use plugin this week, while nothing is on fire. ## Resources - [class-theme-upgrader.php at the WordPress 7.1 tag](https://github.com/WordPress/wordpress-develop/blob/7.1/src/wp-admin/includes/class-theme-upgrader.php) (clear_destination and delete_old_theme)- [wp-includes/load.php at the WordPress 7.1 tag](https://github.com/WordPress/wordpress-develop/blob/7.1/src/wp-includes/load.php) (wp_get_mu_plugins)- [wp_get_custom_css_post() in the WordPress developer reference](https://developer.wordpress.org/reference/functions/wp_get_custom_css_post/)- [Use selector in the custom CSS tab](https://elementor.com/help/how-to-use-selector-in-the-custom-css-tab/) (Elementor, last updated 14 January 2024)- [Add custom code](https://elementor.com/help/custom-code-pro/) (Elementor, last updated 14 January 2024)- [Custom CSS in Elementor](https://elementor.com/help/custom-css-in-elementor/) (Elementor, last updated 14 January 2024)- [Clear Files and Data](https://elementor.com/help/regenerate-css-data/) (Elementor, last updated 20 January 2026)- [Elementor frontend.php source on GitHub](https://github.com/elementor/elementor/blob/main/includes/frontend.php) (the enqueue hook order) ## More on elementor - [Why Elementor Blocks SVG Uploads, and the Security Tradeoff You Are Actually Making](https://adityaarsharma.com/elementor-svg-upload-security-tradeoff/)- [Elementor Performance: What Actually Costs You, Measured](https://adityaarsharma.com/elementor-performance-what-actually-costs-you/)- [Elementor vs Gutenberg in 2026: An Honest Read for People Who Ship](https://adityaarsharma.com/elementor-vs-gutenberg-2026/) --- # AI Memory Platform Comparison: Eleven Projects, Six Columns, Every Cell Sourced Source: https://adityaarsharma.com/ai-memory-platform-comparison/ LightRAG's own README says its four default storage backends are in-memory databases with local file persistence, suitable only for small-scale testing, evaluation and debugging, and not suitable for production. That sentence is in the project documentation, in bold, from the maintainers. It appears in almost no comparison of AI memory platforms, including the ones that put LightRAG at number one. ![The GitHub repository page for HKUDS/LightRAG showing an MIT license, 39.4k stars and v1.5.7 as the latest release.](https://adityaarsharma.com/wp-content/uploads/2026/09/4c47a635-032d-42a6-b83e-c4b15ffeb6be_2800x1900-scaled.png)github.com/HKUDS/LightRAG, screenshot taken 3 September 2026. MIT, latest release v1.5.7. ![Days since the last published release. One of these is not like the others.](https://adityaarsharma.com/wp-content/uploads/2026/09/6fc12960-e7eb-4e64-b4ad-f3df9848d601_2912x1632-scaled.png) So this post is one table, eleven projects, six columns, and every cell traced to the document it came from. Star counts, licences and release dates come from the GitHub REST API on 3 September 2026. Storage and retrieval details come from each project's own README or documentation, read the same day. Nothing here came from memory, and you can re-run all of it. ## How to read the columns - **Storage backend** means the default you get when you configure nothing. Almost every project supports Postgres or Neo4j as an option. That tells you nothing. The default tells you what the maintainers expect you to run.- **Persistence** means what survives a process restart, and where it physically lives.- **Retrieval** means the primary mechanism that decides what comes back.- **Self-hostable** means the project documents running it yourself without a hosted account.- **Licence** is the SPDX identifier from the GitHub API, not the badge in the README.- **Last release** is the `published_at` of the latest published release, which is not the same as the last commit. That gap is often the most informative number on the row. ## The table | Project | Default storage backend | Persistence | Retrieval method | Self-hostable | Licence | Last release | | ------- | ----------------------- | ----------- | ---------------- | ------------- | ------- | ------------ | | mem0 | Qdrant vector store | Vector store on disk | LLM extraction at write, then vector search over extracted memories | Yes | Apache-2.0 | ts-v3.1.8, 2 Sep 2026 | | MemPalace | ChromaDB, embedded | Local palace directory | Verbatim storage, semantic search over local embeddings, scoped by wing and room | Yes | MIT | v3.9.0, 31 Aug 2026 | | Graphiti | Neo4j 5.26 or FalkorDB 1.1.2 or Neptune | Graph database you operate | Hybrid: embeddings, BM25 and graph traversal, facts carry validity windows | Yes | Apache-2.0 | mcp-v1.1.0, 1 Sep 2026 | | Cognee | Ladybug/Kuzu graph plus LanceDB vectors, SQLite relational | Embedded stores on disk | Graph plus vector, ontology grounded | Yes | Apache-2.0 | v1.5.3, 23 Aug 2026 | | LightRAG | JsonKVStorage, NanoVectorDBStorage, NetworkXStorage | Files under WORKING_DIR, dataset held in process memory | Dual-layer knowledge graph plus vector embeddings | Yes | MIT | v1.5.7, 2 Sep 2026 | | Basic Memory | Markdown files plus local SQLite index | Plain files on your disk | Full-text by default, optional hybrid vector with FastEmbed and optional reranking | Yes | AGPL-3.0 | v0.23.2, 25 Aug 2026 | | Supermemory | Embedded graph engine, data in ./.supermemory | One local directory | Hybrid search over automatically extracted memories, scoped by container tag | Yes, one binary | MIT | server-v0.0.8, 17 Aug 2026 | | Khoj | pgvector/pgvector:pg15 via docker-compose | Postgres volume | Vector search over indexed documents | Yes, documented | AGPL-3.0 | 2.0.0-beta.28, 26 Mar 2026 | | codebase-memory-mcp | SQLite in ~/.cache/codebase-memory-mcp | SQLite, WAL mode, survives restart | openCypher subset, BM25 via FTS5, bundled nomic-embed-code vectors | Yes, no service to run | MIT | v0.10.8, 19 Aug 2026 | | Graphify | graph.json in graphify-out/ | A JSON file in your working tree, 512 MiB cap | Graph traversal. No embeddings and no vector store by design | Yes, local by default | Apache-2.0 | v0.9.53, 30 Aug 2026 | | Letta | See note below | See note below | See note below | Yes | Apache-2.0 | 0.16.8, 14 May 2026 | ![Quote card carrying the LightRAG README line that its four default storage backends are not suitable for production.](https://adityaarsharma.com/wp-content/uploads/2026/09/500a8a1f-23e2-4e4c-b3e4-11845ce9db10_2400x3000-scaled.png)The sentence in full, from the LightRAG README, read 3 September 2026. ## Source for every cell Taking them in the order they appear, with the document each claim came from. - **mem0.** The README states mem0 requires a language model to function with `gpt-5-mini` as the default, and uses `text-embedding-3-small` as the default embedding model. The vector store overview at `docs.mem0.ai/components/vectordbs/overview` states that if no configuration is supplied, a default configuration is applied and Qdrant is used.- **MemPalace.** The README states it stores conversation history as verbatim text, does not summarise, extract or paraphrase, and that the storage backend table lists chroma as the default with sqlite_exact, milvus, qdrant and pgvector as alternatives. Embeddings are local by default, around 30 MB for MiniLM or 300 MB for the multilingual option.- **Graphiti.** The README lists Neo4j 5.26, FalkorDB 1.1.2, Amazon Neptune with OpenSearch Serverless, or Kuzu 0.11.2 which it marks deprecated because the upstream project is no longer maintained. It also states Graphiti defaults to OpenAI for inference and embedding and expects an `OPENAI_API_KEY`. Retrieval is described as combining semantic embeddings, keyword BM25 and graph traversal.- **Cognee.** The README's backend table gives Ladybug/Kuzu as the default graph store and LanceDB as the default vector store, with SQLite or Postgres always present as the shared relational database, and states that local development stays fully embedded with no extra services. Indexing requires `LLM_API_KEY`.- **LightRAG.** The README names all four defaults explicitly: `JsonKVStorage`, `NanoVectorDBStorage`, `NetworkXStorage` and `JsonDocStatusStorage`, and its `env.example` sets exactly those. The same section says the whole dataset resides in the server process's memory with files under `WORKING_DIR` serving only as persistence, so capacity is bounded by available RAM.- **Basic Memory.** The README describes files plus a local SQLite index with no servers required, and lists semantic vector search with FastEmbed embeddings on SQLite or Postgres plus optional cross-encoder reranking as features you enable with environment variables.- **Supermemory.** The README's local section says first boot sets up an embedded graph engine and local embeddings, defaults to `Xenova/bge-base-en-v1.5` with no API key, serves the full Memory API on `localhost:6767`, and keeps everything in a `./.supermemory` directory. Scoping is by `containerTag`.- **Khoj.** The self-hosting docs point at a `docker-compose.yml` in the repository. I pulled that file from the master branch and its database service is `docker.io/pgvector/pgvector:pg15`, alongside a SearXNG container and a sandbox container.- **codebase-memory-mcp.** The README states SQLite databases stored at `~/.cache/codebase-memory-mcp/`, persisting across restarts in WAL mode, resettable by deleting the directory, with `CBM_CACHE_DIR` as the override. It documents a read-only openCypher subset, BM25 through SQLite FTS5, and a `semantic_query` tool backed by `nomic-embed-code` embeddings compiled into the binary.- **Graphify.** The README states the output is `graph.html`, `GRAPH_REPORT.md` and `graph.json` in `graphify-out/`, that it is not a vector index and uses no embeddings and no vector store, and that `GRAPHIFY_MAX_GRAPH_BYTES` overrides a 512 MiB cap on `graph.json`. ## The Letta row, and why it is a note instead of data `letta-ai/letta` has 24,587 stars and appears in most comparisons in this category. Its README is 1,942 bytes and states that the repository now serves as a landing page, that the current source lives in `letta-ai/letta-code`, and that the retired V1 server is preserved on an `archive` branch which is unsupported, receives no fixes or security updates, and should not be used in production. ### The live project is letta-code So the row you would fill in from that repository describes software the maintainers have told you not to run. The live project is `letta-ai/letta-code`: 3,190 stars, Apache-2.0, TypeScript, last release v0.31.11 on 1 September 2026, installed with `npm install -g @letta-ai/letta-code`. Both figures from the GitHub API on 3 September 2026. ![The GitHub repository page for letta-ai/letta, the repository whose README says it now serves as a landing page.](https://adityaarsharma.com/wp-content/uploads/2026/09/1125e634-1594-4cf5-a16b-e6e36757d57c_2800x1900-scaled.png)github.com/letta-ai/letta, screenshot taken 3 September 2026. 24,589 stars on a repository the maintainers point away from. ### Why the row stays in, with a pointer I have left the row in with a pointer rather than deleting it, because deleting it is how the mistake propagates. If you see Letta in a comparison table with a storage backend listed and no note, the author did not open the repository. ## Four things the table says that the marketing does not ### 1. A release date and a push date are different numbers Khoj's repository was pushed to on 2 August 2026. Its latest published GitHub release is 2.0.0-beta.28 from 26 March 2026. That is five months of active development with nothing tagged for release. It is not abandonment, and it does mean that installing from a release gets you something quite old, while installing from master gets you something untagged. `R=khoj-ai/khoj curl -s https://api.github.com/repos/$R | python3 -c "import json,sys;print(json.load(sys.stdin)['pushed_at'])" curl -s https://api.github.com/repos/$R/releases/latest | python3 -c "import json,sys;print(json.load(sys.stdin)['published_at'])" # run this on any project before you trust a maintenance claim` ### 2. Self-hostable is a spectrum, not a checkbox Every project in that table answers yes. Behind the yes, the range runs from a single static binary with no runtime at one end to a Neo4j cluster plus an OpenAI key at the other. codebase-memory-mcp and Supermemory each ship a binary. Basic Memory needs Python and writes files. Khoj brings up four containers. Graphiti wants a graph database you keep alive. Those are four different commitments wearing one word. I costed what the heavier end looks like as a monthly bill in [the breakdown of what self-hosted AI memory costs to run](https://adityaarsharma.com/self-hosted-ai-memory-cost/). ### 3. Licence matters more here than in most categories Three licences appear: MIT, Apache-2.0 and AGPL-3.0. The AGPL entries are Basic Memory and Khoj. AGPL-3.0 carries a network-use clause, which is a real consideration if you intend to offer the memory layer to your own users over a network as part of a product. For personal or internal use it changes nothing. This is not legal advice, and it is the one column where reading the actual LICENSE file rather than a badge is worth ten minutes. ### 4. Two projects here refuse embeddings on principle, and both are code tools Graphify says it plainly: not a vector index, no embeddings, no vector store. codebase-memory-mcp uses vectors only for one optional tool and compiles the model into the binary so there is no service. Both are indexing code, where the structure is already explicit in the source and an embedding would be throwing that away. Every project in the table that indexes conversations uses embeddings, because conversations have no such structure. That is the actual dividing line in this field, and I walked through both code tools in detail in [the codebase-memory-mcp and Graphify comparison](https://adityaarsharma.com/codebase-memory-mcp-vs-graphify/). ## What this table cannot tell you ### Retrieval quality Every project publishes benchmark numbers and every set is vendor-run on a harness the vendor chose. Graphify discloses more of its method than most, naming its judge agreement rate. That is better practice and it is still self-scored. Two systems can report near-identical numbers on a benchmark with the same name while measuring different quantities, which I went through in [the mem0 and MemPalace benchmark comparison](https://adityaarsharma.com/mem0-vs-mempalace/). ### Whether you need a memory platform at all It also cannot tell you whether you need a memory platform at all rather than retrieval over your documents, which is a question about architecture and protocol that I unpicked in [the piece on MCP servers versus RAG](https://adityaarsharma.com/mcp-server-vs-rag/). If the constraint pushing you off a specific tool is its mandatory model key, the replacements are in [the Cognee alternatives breakdown](https://adityaarsharma.com/cognee-alternatives/), and the whole cluster sits under [the AI memory tools comparison](https://adityaarsharma.com/ai-memory-tools-compared/). ### One thing to do in the next ten minutes Take whichever of these eleven you are closest to adopting, open its README, and search it for the word 'production'. LightRAG will tell you its defaults are not for production. Cognee will tell you its Postgres graph store is a demo feature. Graphiti will tell you Kuzu is deprecated. Those sentences are in the documentation and almost never in the comparison posts, mine included until I went and looked. ## Resources - [GitHub REST API: repositories](https://docs.github.com/en/rest/repos/repos) - source of every licence, star and push date above- [GitHub REST API: releases](https://docs.github.com/en/rest/releases/releases) - source of every release date above- [LightRAG on GitHub](https://github.com/HKUDS/LightRAG) - the default storage warning is in the Features section- [mem0 on GitHub](https://github.com/mem0ai/mem0) and [its vector store docs](https://docs.mem0.ai/components/vectordbs/overview)- [MemPalace on GitHub](https://github.com/MemPalace/mempalace) - storage backend table- [Graphiti on GitHub](https://github.com/getzep/graphiti) - graph database versions and the Kuzu deprecation- [Cognee on GitHub](https://github.com/topoteretes/cognee) - backend defaults table- [Basic Memory on GitHub](https://github.com/basicmachines-co/basic-memory)- [letta-ai/letta-code](https://github.com/letta-ai/letta-code) - the live Letta source- [Cognee: Knowledge Engine for AI Agent Memory](https://www.youtube.com/watch?v=8hmqS2Y5RVQ) on the cognee channel - a short walkthrough of the graph plus vector approach from the maintainers- [SPDX licence list](https://spdx.org/licenses/) - for reading the licence column properly ## More on AI memory - [AI Memory for Writers: What Survives the Round Trip](https://adityaarsharma.com/ai-memory-for-writers/)- [AI Memory With Tagging: Tags, Embeddings and Graph Edges Are Not Interchangeable](https://adityaarsharma.com/ai-memory-with-tagging/) --- # Hardening wp-admin: What Actually Costs an Attacker Something Source: https://adityaarsharma.com/hardening-wp-admin-what-actually-helps/ The Patchstack vulnerability database lists eight entries for WPS Hide Login, the most installed "change your login URL" plugin on WordPress.org. Six of them are the same bug wearing different clothes: *Multiples Security Issues* (24 July 2019), *Secret login page location disclosure* (27 January 2020), *Login Page Protection Bypass* (5 February 2021), *Protection Bypass with Referer-Header* (27 October 2021), *Secret Login Page Location Disclosure on Multisites* (10 January 2024), *Login Page Disclosure* (CVE-2024-2473, 10 June 2024) and *Hidden Login Page Disclosure* (15 July 2024). I read that history on 3 September 2026. ![Advisories against the most installed login-hiding plugin that are about the hidden URL leaking.](https://adityaarsharma.com/wp-content/uploads/2026/09/8bee7bcb-b5b4-4c9d-b83b-6a99577c2b43_2400x2400.png) That is the shape of the whole problem with WordPress hardening advice. Six of these eight advisories exist because the thing the plugin does is keep a secret, and secrets in HTTP leak. The plugin is not badly written. The category is. So I went through the six measures that appear in every hardening checklist and sorted them by a single question: after you apply this, what does the attacker have to do that they did not have to do before? Not "does it sound safer". What changes in the work. ![The Patchstack vulnerability history page for WPS Hide Login, showing eight patched advisories.](https://adityaarsharma.com/wp-content/uploads/2026/09/2360bbb0-d652-492a-b318-a59d37e1057a_2800x2100-scaled.png)patchstack.com/database/wordpress/plugin/wps-hide-login/vulnerabilities, screenshot taken 3 September 2026. Eight patched, none present. ## The ranking, and the rule behind it Three groups fall out. - **Removes a capability.** After the change, the code path an attacker needs does not exist or cannot be reached. This is the only group that survives an attacker who already knows everything about your setup.- **Adds a check.** The attacker now has to defeat a specific control. Worth having, but the value is entirely in the implementation, and most of these are weaker than the marketing suggests.- **Hides something.** The value lasts until the secret leaks, and hidden values in a web application leak through redirects, emails, error pages and the hiding code itself. Sorted that way: - **Network or server-level restriction on wp-login.php and wp-admin** (removes a capability)- **DISALLOW_FILE_MODS** (removes a capability)- **Two-factor authentication that also covers the API paths** (adds a strong check)- **Limit login attempts** (adds a check whose strength depends entirely on how it identifies the client)- **DISALLOW_FILE_EDIT** (removes a small capability, leaves the larger one open)- **Disabling XML-RPC** (mostly hides, and usually not the thing you meant to remove)- **Renaming the login URL** (hides, and the hiding is its own bug class) ## 1. Restricting wp-login.php and wp-admin at the server ### It stops the request before PHP checks a password This is the only measure on the list that stops an unauthenticated request from reaching the PHP that checks passwords. `# nginx: allow one office range and one VPN exit, deny the rest location = /wp-login.php { allow 203.0.113.0/24; allow 198.51.100.7; deny all; include fastcgi_params; fastcgi_pass unix:/run/php/php8.3-fpm.sock; }` Everything else on this list lets the request in and then argues with it. ### The cost, said out loud The cost is real and you should say it out loud before you do this: you lock yourself out from a mobile connection, and a blanket deny on all of `/wp-admin/` breaks logged-out AJAX. WordPress core says so in its own hardening guide: "Simply securing the wp-admin/ directory might also break some WordPress functionality, such as the AJAX handler at wp-admin/admin-ajax.php." So restrict `wp-login.php` first, and if you also want `/wp-admin/`, carve out `admin-ajax.php`. Then check whether your front end still works while logged out, because that is where this breaks and nobody notices for a week. ## 2. DISALLOW_FILE_MODS, the strongest line in wp-config.php This one line removes a whole class of post-compromise action: `define( 'DISALLOW_FILE_MODS', true );` ### What the constant actually maps to do_not_allow The mechanism is in core, not in a plugin. `wp_is_file_mod_allowed()` lives in `wp-includes/load.php` and returns the value of the `file_mod_allowed` filter, defaulting to the inverse of the constant. `wp-includes/capabilities.php` then maps `install_plugins`, `upload_plugins`, `update_plugins`, `delete_plugins`, the theme equivalents, `update_core`, `edit_plugins`, `edit_themes` and `edit_files` all to `do_not_allow`. I read this in WordPress 6.9.4 on 3 September 2026. ### What an attacker keeps, and what they lose An attacker with a valid administrator session on a site with this constant set cannot install a plugin, cannot upload a zip, cannot edit theme files and cannot update core. They can still change content, add users and read your data. That is a much smaller blast radius than the default, where an admin session is a shell. The cost: your deploys move to git, WP-CLI or your host, and core auto-updates stop. If you already deploy from a repository you are paying nothing for this. ## 3. Two-factor, and the question to ask about it WordPress core has no two-factor authentication. Every implementation is a plugin, and the difference between a good one and a decorative one is what it does about the paths that never touch the login form. ### The Two Factor plugin, and the API paths The Two Factor plugin from the core feature project handles it explicitly. `Two_Factor_Core::is_user_api_login_enabled()` defaults to `(bool) did_action( "application_password_did_authenticate" )`, which means a REST or XML-RPC request is allowed through without a second factor only when it authenticated with an application password. Its own settings screen says the quiet part: "Authentication for REST API and XML-RPC must use application passwords (defined above) instead of your regular password." That is the right design and it moves the risk rather than deleting it. An application password is 24 characters, has no scope and no expiry, and carries every capability the account has. I pulled that apart separately in [the piece on what an application password actually grants](https://adityaarsharma.com/wordpress-application-passwords-security-model/). If you turn on 2FA and then hand out an admin application password to a plugin, you have added a check to one door and cut a new one next to it. ![The Hardening WordPress page in the WordPress Advanced Administration Handbook.](https://adityaarsharma.com/wp-content/uploads/2026/09/d1909112-fb1a-44b8-ba4c-b6cd24e9846d_2800x2100-scaled.png)developer.wordpress.org/advanced-administration/security/hardening/, screenshot taken 3 September 2026. The admin-ajax.php warning quoted above is on this page. ## 4. Limit login attempts, and the header it trusts ### The counter is only as good as the IP it trusts The mechanism is a counter keyed on the client IP address. So the only question that matters is where the plugin gets that address from. NVD, published 21 December 2020, on CVE-2020-35590: "LimitLoginAttempts.php in the limit-login-attempts-reloaded plugin before 2.17.4 for WordPress allows a bypass of (per IP address) rate limits because the X-Forwarded-For header can be forged. When the plugin is configured to accept an arbitrary header for the client source IP address, a malicious user is not limited to perform a brute force attack, because the client IP header accepts any arbitrary string." ![The NVD detail page for CVE-2020-35590, base score 9.8 critical, published 21 December 2020.](https://adityaarsharma.com/wp-content/uploads/2026/09/d2a0280e-e177-4cdb-b827-d825d3a84881_2800x2100-scaled.png)nvd.nist.gov/vuln/detail/CVE-2020-35590, screenshot taken 3 September 2026. NVD published date 21 December 2020, CVSS 3.x base score 9.8. This is not a slur on that plugin, which fixed it in 2.17.4. It is the general failure mode. If your site sits behind Cloudflare or a load balancer, the plugin has to read a forwarded header to see the real visitor, and a forwarded header is attacker-controlled unless the edge overwrites it and the origin only accepts connections from the edge. Most WordPress installs get the first half of that right and skip the second. Two things to check on yours: - Which header the plugin is configured to trust, and whether your origin server is reachable directly by IP. If it is, that header is a free pass.- Whether the counter covers `xmlrpc.php` and `/wp-json/` or only `wp-login.php`. A login limiter that only watches the login form is a lock on one of three doors. `# does the REST API answer an authentication attempt at all? curl -s -o /dev/null -w "%{http_code}\n" \ -u 'admin:definitelywrong' \ https://example.com/wp-json/wp/v2/users/me # and XML-RPC curl -s -X POST -d 'system.listMethods' \ https://example.com/xmlrpc.php | head -c 400` ## 5. DISALLOW_FILE_EDIT closes one door in a room with two The core documentation is unusually blunt about this one. From the Hardening WordPress page in the Advanced Administration Handbook: placing `define( "DISALLOW_FILE_EDIT", true );` in wp-config.php "is equivalent to removing the edit_themes, edit_plugins and edit_files capabilities of all users", and then, immediately: "This will not prevent an attacker from uploading malicious files to your site, but might stop some attacks." ### Three capabilities, and the one that is missing Read the capability list again. Three capabilities, all of them about the built-in code editor. `install_plugins` is not among them. An administrator session on a site with `DISALLOW_FILE_EDIT` set can still upload a plugin zip containing whatever it likes. That is why measure 2 is above this one: `DISALLOW_FILE_MODS` maps the editor capabilities *and* the install capabilities to `do_not_allow`. If you are setting one constant, set that one. ## 6. Disabling XML-RPC, and the two reasons people give for it The first reason is brute-force amplification: `system.multicall` lets you pack hundreds of password guesses into one HTTP request. Read the current server class and that argument has gone stale. `wp-includes/class-wp-xmlrpc-server.php` carries a protected `$auth_failed` flag, described in its own docblock as "Flags that the user authentication has failed in this instance of wp_xmlrpc_server". In `login()`, if `$auth_failed` is already true the call short-circuits to a `login_prevented` error without calling `wp_authenticate()` at all. One failed credential per HTTP request. That is the same budget as the login form. ### Pingback abuse, and the filter that does not do it The second reason is pingback abuse, and here the standard advice does not do what people think. The filter everyone is told to use has a docblock in core that reads, in part: "Contrary to the way it is named, this filter does not control whether XML-RPC is fully enabled, rather, it only controls whether XML-RPC methods requiring authentication, such as for publishing purposes, are enabled. Further, the filter does not control whether pingbacks or other custom endpoints that do not require authentication are enabled." So `add_filter( "xmlrpc_enabled", "__return_false" );` leaves `pingback.ping` answering. If pingback reflection is your actual concern, filter `xmlrpc_methods` and `unset()` both `pingback.ping` and `pingback.extensions.getPingbacks`. Or, at the server, deny `/xmlrpc.php` outright, which is the honest version of "disable XML-RPC" and the one most people meant. Check your access log for real XML-RPC clients first, because the Jetpack app and some publishing tools still use it. [Reading your access logs](https://adityaarsharma.com/reading-wordpress-access-logs/) covers how to count what is actually hitting the endpoint before you close it. ## 7. Renaming the login URL This is the one I will call theatre, and I want to show the reasoning rather than assert it. ### Where the secret leaks A renamed login URL protects you only while the new URL is unknown. Enumerate where it can become known and the picture settles itself: - Any redirect from a protected admin URL to the login screen carries the new path in a `Location` header.- Password-reset and new-user emails generated by core contain a login link.- A plugin or theme that hard-codes `wp-login.php` in a redirect gives you a second, unrenamed entrance.- The plugin doing the hiding has its own bugs, which is the eight-advisory history I opened with. And on the other side of the ledger, the renamed URL does nothing at all to `xmlrpc.php` or `/wp-json/`, because neither of those goes anywhere near the login page. Credential stuffing against the REST API does not care what your login page is called. If you rename the login and leave the API paths answering authentication attempts, you have moved the door and left the window. ### The honest version of the benefit The honest version of the benefit: it drops the volume of dumb bot noise in your logs, which makes real events easier to see. That is a log-hygiene win, and it is worth having for that reason. It is not access control, and it should never be the reason you skipped items 1 through 4. ## What none of this touches Three attacker paths are unaffected by every item above, and they are the ones that have actually taken sites down recently. - A plugin you installed on purpose that shipped hostile code. The delivery channel gets compromised, not the code. [The supply chain post](https://adityaarsharma.com/wordpress-plugin-supply-chain-documented-incidents/) walks five documented cases with primary sources.- A stolen application password, which is a valid credential and looks like one in every log line.- A payload that is already on disk and only reveals itself to certain visitors. [The time a scanner said clean while the site served spam to Googlebot](https://adityaarsharma.com/the-scanner-said-clean-the-site-was-serving-spam-to-googlebot/) is what that looks like from outside. Which is the argument for spending your next hour on detection rather than on a seventh hardening plugin. And for the accounts themselves, a hardware key beats every password policy you can write: [YubiKey versus password managers](https://adityaarsharma.com/yubikey-vs-password-managers-which-one-to-choose/) is the comparison I keep coming back to. ## Do this today Run the two curl commands above against your own site. If `/wp-json/wp/v2/users/me` returns 401 to a wrong password and `xmlrpc.php` answers `system.listMethods`, then both of those paths accept authentication attempts and your login limiter needs to cover them. That takes about ninety seconds and it tells you whether the hardening you already installed is guarding the doors you actually have. ## Resources - [Hardening WordPress, WordPress Advanced Administration Handbook](https://developer.wordpress.org/advanced-administration/security/hardening/)- [WPS Hide Login vulnerability history, Patchstack database](https://patchstack.com/database/wordpress/plugin/wps-hide-login/vulnerabilities)- [CVE-2020-35590, NVD](https://nvd.nist.gov/vuln/detail/CVE-2020-35590)- [Two Factor plugin source, WordPress on GitHub](https://github.com/WordPress/two-factor)- [xmlrpc_enabled filter reference, WordPress Developer Resources](https://developer.wordpress.org/reference/hooks/xmlrpc_enabled/)- [wp-config.php constants, WordPress Developer Resources](https://developer.wordpress.org/apis/wp-config-php/) --- # What Web Fonts Actually Cost on WordPress, Measured With curl Source: https://adityaarsharma.com/wordpress-fonts-what-they-actually-cost/ My homepage was preloading three font files. All three returned 404. I found that on the morning of 3 September 2026 with one command: ![My font preloads cost more than my fonts.](https://adityaarsharma.com/wp-content/uploads/2026/09/e1196822-d4b2-4d76-98fe-369297115e58_2912x1632-scaled.png)`curl -s -o /dev/null \ -w '%{http_code} %{size_download} %{content_type}\n' \ https://adityaarsharma.com/wp-content/cache/perfmatters/adityaarsharma.com/fonts/pxiEyp8kv8JHgFVrJJfecnFHGPc.woff2 404 82010 text/html; charset=UTF-8`Three preload hints, three 404 pages. Each 404 page is 82,010 bytes of HTML, 24,100 bytes after gzip. So the browser is instructed, at the highest priority the platform offers, to fetch 72,300 bytes of my own *Page Not Found* template and then throw it away. The fonts that actually render the page are somewhere else entirely, and they get discovered late, the ordinary way, through CSS. This is my site. I configured it. It has been shipping that bug for long enough that I cannot date it, and no tool told me, because every performance tool I own reports the page as fast. This post is what I learned pulling it apart, and the numbers are all measured rather than quoted. ## Why there were two font systems on one page The site runs Elementor with the Google Fonts Local option turned on, and Perfmatters on top of it. Both plugins solve the same problem and neither knows the other did. ### What Elementor writes Elementor writes the CSS. It downloads Google's woff2 files into the uploads directory and rewrites the `@font-face` rules to point there. The rules it writes point at `/wp-content/uploads/elementor/google-fonts/fonts/`, with a `unicode-range` descriptor per subset. Those are its own local copies of a Google revision of the family, not a renamed copy of anything Perfmatters holds. Those URLs work. I checked five of them and every one returned 200 with a real woff2 body. ### What Perfmatters writes Perfmatters writes the preload hints. It has its own local-fonts cache directory and it emits `<link rel=preload>` tags pointing into it. The preload it emits points at `/wp-content/cache/perfmatters/adityaarsharma.com/fonts/...woff2`, with `as="font" type="font/woff2" crossorigin`. Nothing was ever written to that directory. It is empty. The three preloads were orphaned configuration: three URLs sitting in Perfmatters' preload list, pointing into a cache directory that had never been populated. The fonts the page actually renders with are Elementor's, discovered late through CSS, exactly as they were before. A preload the browser cannot resolve is not a partial win. It is a full-priority download of my own Page Not Found template, competing for bandwidth with the font the CSS was about to ask for anyway. ### Why a broken preload stays silent The mechanism worth taking away: `rel=preload` matching is string matching on the resolved URL, plus the CORS mode. Fonts are always fetched in CORS mode, which is why the `crossorigin` attribute is mandatory on a font preload even when the font is same-origin. Get the URL wrong by one character, or drop `crossorigin`, and the browser downloads the resource twice and warns about it in the console. Chrome's warning is the one everyone has seen and ignored: the resource was preloaded but not used within a few seconds. ## What the font files actually weigh Before deciding whether any of this matters, I wanted the real byte counts rather than the ones people repeat. I asked the Google Fonts API for Poppins at two weights, then fetched every woff2 it referenced: The method: fetch `fonts.googleapis.com/css2?family=Poppins:wght@400;600&display=swap` with a browser user agent, pull every `fonts.gstatic.com` URL out of the response with `grep -o`, then measure each one with `curl -w "%{size_download}"`. - 39,504 bytes, Poppins 400 devanagari- 5,640 bytes, Poppins 400 latin-ext- 7,900 bytes, Poppins 400 latin- 39,384 bytes, Poppins 600 devanagariMeasured 3 September 2026 against fonts.gstatic.com, Poppins v24. The whole latin subset at 400 weight is 7,900 bytes. That is smaller than most of the CSS files on my homepage. Two weights of latin come to 15,892 bytes. ![82,010 bytes of 404 HTML against a 7,900 byte font file](https://adityaarsharma.com/wp-content/uploads/2026/09/c8884aeb-414f-43c5-922a-2861ca08fdcf_2912x1632-scaled.png)Measured with curl against adityaarsharma.com and fonts.gstatic.com, 3 September 2026.Now compare that to the 72,300 gzipped bytes of 404 HTML my preloads were pulling. The bug cost more than four times what the fonts cost. It is a useful reminder that on a site with a page cache in front of it, most of the remaining damage is not weight, it is wrong requests. I made the same point measuring the whole asset list in [what actually makes a WordPress site slow](https://adityaarsharma.com/what-actually-makes-a-wordpress-site-slow/), where the single heaviest file on the page turned out to belong to Google Tag Manager, not to WordPress. ### The devanagari subset is the expensive one The devanagari subset is the interesting line. At 39,504 bytes it is five times the latin subset, and if you serve a font without `unicode-range` declarations you serve that to every visitor whether or not a single Devanagari codepoint appears on the page. This is the single largest self-inflicted font cost I see on Indian WordPress sites, and it comes from well-meant self-hosting that concatenates all the subsets into one file. ## The render-blocking path, precisely A font never blocks rendering. The stylesheet that declares it does. The chain with Google Fonts, hosted, is: - HTML arrives. The parser finds `<link href="https://fonts.googleapis.com/css2?...">`.- DNS, TCP and TLS to fonts.googleapis.com. Rendering is blocked from here until the CSS arrives, because it is a stylesheet in the head.- The CSS arrives. It contains `@font-face` rules pointing at a second host, fonts.gstatic.com.- DNS, TCP and TLS to fonts.gstatic.com. Rendering is no longer blocked, but text using the font is now in its block period.- The woff2 arrives. Text repaints. ### What each connection actually costs Two full connection setups on the critical path before a single glyph exists. I measured what each one costs from my laptop, on a connection that Cloudflare routes through its Singapore colo (the cf-ray header on every response this morning ended in SIN): The loop timed `dns`, `connect` and `tls` against `fonts.googleapis.com`, `fonts.gstatic.com` and my own domain, two seconds apart. Google's edge answered in 0.017s for DNS and connected faster than my own origin did. Here is the part that gets misreported. Google's edge is faster to connect to than my own Cloudflare-fronted origin. 90ms to first byte of TLS against 181ms. If you self-host and you tell people you saved them a slow third-party connection, measure it, because on my connection the third party was the fast one. ### The real reason to self-host The reason to self-host is not speed of that one hop. It is that self-hosting collapses two connection setups into zero new ones, because the font comes off a connection the browser already opened for the HTML. Google Fonts costs you two new hosts even when each host is quick. The saving is roughly one full handshake, and on a warm HTTP/2 or HTTP/3 connection to your own origin it is close to free. The second reason, in the European Union, is legal rather than technical. The Landgericht Munchen I ruled on 20 January 2022, case 3 O 17493/20, that a dynamic IP address is personal data for a site operator, that passing it to Google by embedding Google Fonts without consent violated the visitor's right of personality, and it awarded 100 euros in damages. If you serve European visitors that judgment settles the question on its own. ## font-display, and what the four values really mean Every one of the 78 `@font-face` rules on my homepage carries `font-display: swap`. I counted them: I fetched the two minified Elementor local-font stylesheets with `curl -s --compressed` and counted with `grep -c -o '@font-face'`. Seventy-eight rules across the two files, every one of them carrying `font-display: swap`. ### The three periods, as MDN defines them ![MDN page for the CSS font-display descriptor](https://adityaarsharma.com/wp-content/uploads/2026/09/49468241-b6db-48a5-acd3-95ce50df7f67_2880x1800-scaled.png)MDN, font-display, last modified 20 April 2026. Screenshot taken 3 September 2026.MDN's page on the descriptor, last modified 20 April 2026, describes the timeline as three periods: a block period where an element using an unloaded font renders invisible text, a swap period where it renders a fallback, and a failure period where the browser gives up and falls back normally. The four values differ only in how long the first two periods are: - `block` gives a short block period and an infinite swap period. Invisible text first.- `swap` gives an extremely small block period and an infinite swap period. Fallback text immediately, replaced whenever the font lands.- `fallback` gives an extremely small block period and a short swap period. If the font is late it is never used on this page load.- `optional` gives an extremely small block period and no swap period. The browser may decline the font entirely on a slow connection. ### Why there are no numbers for short MDN is deliberate about not giving you numbers for *short* and *extremely small*. The durations are defined by the user agent, and Firefox exposes them as the preferences `gfx.downloadable_fonts.fallback_delay` and `gfx.downloadable_fonts.fallback_delay_short`. Everyone quotes 3 seconds and 100 milliseconds as if they were in the spec. They are implementation defaults, not requirements, and I could not verify them from a normative source, so I am not going to repeat them as fact. The practical read: `swap` trades a flash of unstyled text for never showing invisible text, and it is the right default for body copy. `optional` is the right choice if you genuinely do not care whether the font renders on a first visit, which is a stronger statement about your brand than most people intend to make. `block` is almost always wrong on the open web and it is the one that produces the complaint that the site loaded blank. ![78 @font-face rules on one homepage](https://adityaarsharma.com/wp-content/uploads/2026/09/e2f67357-25bd-4218-8645-6e59069214b3_2400x2400.png)Counted with grep across the two Elementor local-font stylesheets, 3 September 2026. ## The layout shift half, which is the part swap does not fix ### Why swap does not fix layout shift `swap` guarantees a repaint. If the fallback font has different metrics from the web font, that repaint moves things, and that movement is scored by Cumulative Layout Shift. I wrote up the thresholds and the field-versus-lab problem in [the Core Web Vitals piece](https://adityaarsharma.com/core-web-vitals-wordpress-2026/); the short version is that CLS is measured on real visits, and a font swap that only shifts text on a cold cache still counts. The fix is metric overrides on a fake fallback face. Instead of writing `font-family: Poppins, sans-serif` and hoping, you declare a local face wrapping the system font, scale it to match, and put it in the stack: `@font-face { font-family: 'Poppins Fallback'; src: local('Arial'); size-adjust: 112%; ascent-override: 105%; descent-override: 35%; line-gap-override: 0%; } body { font-family: 'Poppins', 'Poppins Fallback', sans-serif; }` ### What the three overrides do `size-adjust` multiplies all glyph outlines and metrics by a percentage, matching ex heights between the two fonts, per the MDN reference for the descriptor. MDN records it as widely available across browsers since September 2023, so this is not a new trick you are gambling on. The override descriptors do the same job for the ascent, descent and line gap, which is what actually determines line box height and therefore how far a paragraph moves when the swap happens. The percentages above are illustrative. The correct ones are specific to the pair of fonts you are matching, and you get them by reading both fonts' `unitsPerEm`, `ascender`, `descender` and `xHeight` from their metrics tables and dividing. I did not measure a before-and-after CLS number on my own site for this post, and I am not going to quote someone else's percentage improvement as if it were mine. ## Four things that do not work **Preloading every weight.** A preload is a promise that the resource is needed immediately. Preload four weights and you have told the browser that four fonts are all more important than your hero image, which is usually the element being measured for Largest Contentful Paint. Preload the one face that renders above the fold, at most two. ### Preloading a URL that does not exist **Preloading a URL the CSS does not request.** That is my bug. The cost is silent because nothing breaks visually, and the only signal is a console warning most people have trained themselves to scroll past. Verify with curl, not with a plugin's dashboard. **Self-hosting without subsetting.** Downloading the family from a font mirror usually gives you one file per weight covering every script the family supports. That is how a 7,900 byte latin subset becomes a 100 kilobyte all-scripts file. If your self-hosted CSS has no `unicode-range` declarations, you did not self-host the font, you self-hosted a bundle. ### Stacking two font plugins **Stacking two font plugins.** This is the general failure and it is not specific to fonts. Two plugins each producing a correct solution to the same problem produce an incorrect combination, because neither can see the other's output. The same class of collision shows up in the asset pipeline generally, which I went through in [the Elementor performance measurements](https://adityaarsharma.com/elementor-performance-what-actually-costs-you/). If you are trying to work out which layer is responsible for a given piece of output, [the caching layers post](https://adityaarsharma.com/wordpress-caching-layers-which-one-is-failing/) has the isolation method: change one layer, measure again, and never trust a dashboard that reports on itself. ## What I did about mine I removed the three stale entries from Perfmatters' preload list. That is the whole fix. I left `fonts.local_google_fonts` switched on, because it was not the culprit. The preloads were config pointing at an empty directory, not a naming mismatch between two plugins. Verified with curl on 3 September 2026: `curl -s https://adityaarsharma.com/ | grep -oE '<link[^>]*rel="preload"[^>]*as="font"[^>]*>' | wc -l` now returns 0. It returned 3 before. I did not add two replacement preloads back. I could not confirm which two faces actually render above the fold, and preloading the wrong face costs bandwidth for nothing. Removing three wrong ones is a verified win. Adding two guesses is not. ## Run this on your own site in the next ten minutes Pull every preloaded font off your homepage and check that each one returns 200 with a font content type. Two commands: `curl -s https://YOURSITE.com/ -o home.html grep -oE '<link[^>]*rel="preload"[^>]*as="font"[^>]*>' home.html \ | grep -oE 'href="[^"]+"' | cut -d'"' -f2 \ | while read u; do curl -s -o /dev/null -w "%{http_code} %{size_download} %{content_type} $u\n" "$u" sleep 2 done`Any line that is not `200` with `font/woff2` is a wasted high-priority request. Any URL that does not appear verbatim inside one of your stylesheets is a duplicate download. Both are free to fix and neither shows up in a page speed score. ## Resources - [MDN: font-display](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display), the three-period timeline and all four values. Last modified 20 April 2026.- [MDN: size-adjust](https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/size-adjust), the metric multiplier used for fallback matching. Widely available since September 2023.- [CSS Fonts Module Level 5](https://drafts.csswg.org/css-fonts-5/#size-adjust-desc), the normative definition of size-adjust.- [Google Fonts Knowledge](https://fonts.google.com/knowledge), including the subsetting and unicode-range behaviour the css2 API applies.- [LG Munchen I, 20 January 2022, 3 O 17493/20](https://rewis.io/urteile/urteil/lhm-20-01-2022-3-o-1749320/), the Google Fonts and IP address judgment, full text in German. --- # robots.txt for AI Crawlers: The Actual User-Agent Strings, and What Each One Honours Source: https://adityaarsharma.com/robots-txt-ai-crawlers-user-agents/ My own robots.txt permits GPTBot to crawl `/wp-json/`. Googlebot cannot. I did not intend either of those things, and the file looks completely reasonable until you read the group-matching rule. ![Vendors who document that their user-initiated fetcher may ignore robots.txt.](https://adityaarsharma.com/wp-content/uploads/2026/09/3d9d65de-5951-4f49-ad82-34df86d3f8cb_2400x2400.png)Here is the relevant part of `https://adityaarsharma.com/robots.txt`, fetched 2 September 2026 and trimmed: `User-agent: * Disallow: /wp-json/ Disallow: /feed Disallow: /wp-admin/ Disallow: /?s=* Disallow: /search/* User-agent: GPTBot User-agent: ChatGPT-User User-agent: OAI-SearchBot User-agent: ClaudeBot User-agent: anthropic-ai User-agent: Claude-Web User-agent: Google-Extended User-agent: PerplexityBot User-agent: CCBot ... 25 more agents ... Allow: /` ### What RFC 9309 says about group matching RFC 9309, the 2022 standardisation of the Robots Exclusion Protocol, is explicit about what happens next. Crawlers match their product token case-insensitively, merge every group that matches, and obey those rules. Then: if no matching group exists, crawlers must obey the group with a user-agent line of `*`. The corollary is the part people miss. If a matching group *does* exist, the `*` group is never consulted at all. ![RFC 9309, the Robots Exclusion Protocol](https://adityaarsharma.com/wp-content/uploads/2026/09/f7d584ff-fd2c-4fd6-972a-01c77bfbbb4e_2880x1800-scaled.png)RFC 9309, Robots Exclusion Protocol, on rfc-editor.org. Screenshot taken 3 September 2026.So GPTBot reads its own group, finds one rule, `Allow: /`, and every Disallow above it is irrelevant. Bing states the same thing in its own words in its [robots.txt help](https://www.bing.com/webmasters/help/how-to-create-a-robots-txt-file-cb7c31ec): on finding instructions for itself, Bingbot ignores the generic section, so you have to repeat the general directives inside the specific one. ### The most common failure in AI crawler blocks That is the single most common failure in AI crawler blocks, and it is produced by exactly the plugins and generators that sell AI crawler control as a feature. Everything below is the vendor documentation, read on 2 September 2026, with the real strings. ## OpenAI: four bots, and only one of them is about search Source: [Overview of OpenAI crawlers](https://platform.openai.com/docs/bots). OpenAI notes the version number in these strings can change. - **GPTBot** is training only. `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.4; +https://openai.com/gptbot`. Blocking it says your content should not be used to train the foundation models. It does nothing to your visibility in ChatGPT search.- **OAI-SearchBot** is search. `...; compatible; OAI-SearchBot/1.4; +https://openai.com/searchbot`. OpenAI says sites opted out of this one will not be shown in ChatGPT search answers, though they can still appear as navigational links. It also warns that a robots.txt change takes about 24 hours to take effect on the search side.- **ChatGPT-User** is user-initiated fetching. `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot`. OpenAI's own wording: because these actions are initiated by a user, "robots.txt rules may not apply."- **OAI-AdsBot** visits pages submitted as ads on ChatGPT. `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-AdsBot/1.0; +https://openai.com/adsbot`. OpenAI says what it collects is not used for training. ### The robots.txt fetch suffix A detail worth having in your log parser: when fetching robots.txt itself, OpenAI may add a `robots.txt` marker into the user agent string, so a real request looks like `...; compatible; OAI-SearchBot/1.4; robots.txt; +https://openai.com/searchbot`. That exists specifically so you can tell policy fetches from content fetches when your log format drops the path. ### The published IP ranges Each bot publishes IP ranges as JSON. I fetched them on 2 September 2026: `curl -s https://openai.com/gptbot.json` returns a `creationTime` of 2025-10-30T11:00:00.000000 and a `prefixes` array of IPv4 blocks, the first being `132.196.86.0/24`. `searchbot.json` and `chatgpt-user.json` have the same shape. ## Anthropic: three bots, and your robots.txt is probably naming two that do not exist Source: Anthropic's support article, [does Anthropic crawl data from the web](https://support.anthropic.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler). Three robots are documented: **ClaudeBot** for content that may contribute to model training, **Claude-User** for websites Claude visits at a user's direction, and **Claude-SearchBot** for search quality. Anthropic states plainly that disabling Claude-User stops content being retrieved in response to a user query, and disabling Claude-SearchBot stops indexing for search, and that both may reduce your visibility. ### The block syntax Anthropic gives you Anthropic supports the non-standard `Crawl-delay` extension, and gives the block syntax directly: `User-agent: ClaudeBot Crawl-delay: 1 User-agent: ClaudeBot Disallow: /`It also says IP blocking is not a supported opt-out, because it stops Anthropic reading your robots.txt in the first place, which is a sound point and one most blocklists get wrong. ### Two of the three names on my file do not exist Now compare that against my own file above. It lists `ClaudeBot`, `anthropic-ai` and `Claude-Web`. Two of those three are not in Anthropic's current documentation at all, and the two agents that actually decide whether Claude can see your page when a person asks about you, Claude-User and Claude-SearchBot, are missing. That list came from a plugin. It is the state of most WordPress AI crawler blocklists: accurate for 2023, stale for the bots that matter now. ## Common Crawl: the most transparent of the set Source: the [Common Crawl FAQ](https://commoncrawl.org/big-picture/frequently-asked-questions/). The current string is `CCBot/2.0 (https://commoncrawl.org/faq/)`; the older one was `CCBot/1.0 (+https://commoncrawl.org/bot.html)`, and they say the version number may increment. ### What CCBot honours What it honours, in their words: it checks robots.txt first and only fetches if crawling is permitted, it obeys `Crawl-delay`, it follows RFC 9309 redirect limits, it backs off on HTTP 429 and 5xx, and it uses your sitemap if robots.txt announces one. It does not execute JavaScript and does not use cookies. IP ranges are published at `index.commoncrawl.org/ccbot.json` with reverse DNS for verification, and there is a separate opt-out registry beyond robots.txt. CCBot is the one bot on this list where you can verify every claim about its behaviour from your own logs, because it identifies itself with a plain string and does not render. ## Perplexity: one bot honours robots.txt, the other says it generally does not Source: [Perplexity crawlers](https://docs.perplexity.ai/guides/bots). - **PerplexityBot**, for surfacing and linking sites in Perplexity search results, not for training. `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)`- **Perplexity-User**, for pages fetched because a user asked a question. `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Perplexity-User/1.0; +https://perplexity.ai/perplexity-user)`. Perplexity's own line: since a user requested the fetch, this fetcher "generally ignores robots.txt rules." ### Two vendors say the same thing about user-initiated fetches That is two vendors, OpenAI and Perplexity, stating in their own documentation that their user-initiated fetchers are outside robots.txt. If your requirement is that no AI system ever renders your content, robots.txt does not deliver it. Only network-level rules do, and that is a different decision with different costs. ### A live gotcha for WAF automation One live gotcha if you automate WAF rules. The documentation gives `https://www.perplexity.com/perplexitybot.json`. On 2 September 2026 that URL returned a 302 to `https://www.perplexity.ai/perplexitybot.json`, which then returned the JSON with a `creationTime` of 2025-02-07. A fetch job written without `curl -L` gets a 302 body and an empty IP set, and empty IP sets in an allow rule fail closed. ## Google-Extended: a control token with no user agent Source: [Google's common crawlers documentation](https://developers.google.com/search/docs/crawling-indexing/google-common-crawlers#google-extended). This is the one most people have backwards, and Google's own table says why in one line: Google-Extended has no separate HTTP request user agent string. Crawling is done with existing Google user agent strings, and the robots.txt token is used purely as a control. ### Three consequences of a token with no user agent ![Google-Extended appears zero times in an access log](https://adityaarsharma.com/wp-content/uploads/2026/09/8b077e86-cf8e-4b6a-bc04-edcef228f774_2400x2400.png)Google's common crawlers documentation, read 2 September 2026.Three consequences. - You will never see Google-Extended in an access log. Any dashboard showing you Google-Extended hits is showing you something it invented.- What the token controls is training of the Gemini models and grounding in Gemini Apps and Grounding with Google Search on Vertex AI.- Google states that Google-Extended does not affect inclusion in Google Search and is not a ranking signal. It therefore does not remove you from AI Overviews or AI Mode. Those are part of Search, and Google's [AI features guidance](https://developers.google.com/search/docs/appearance/ai-features) says the control for them is Googlebot in robots.txt plus `nosnippet`, `data-nosnippet`, `max-snippet` or `noindex`. Since 31 August 2026 there is also a Search generative AI control in Search Console settings, which defaults to including your site. ## Bingbot: there is no separate AI crawler Source: [Overview of Bing crawlers](https://www.bing.com/webmasters/help/which-crawlers-does-bing-use-8c184ec0). Bing lists Bingbot, AdIdxBot for ads, BingPreview, MicrosoftPreview and BingVideoPreview. No AI-specific token. Bingbot's string, with the Edge version as a placeholder: The string is `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm) Chrome/W.X.Y.Z Safari/537.36`. ### One crawl feeds both Which means the crawl that feeds Copilot answers and Bing's AI summaries is the same crawl that feeds blue links, and a Bingbot block removes you from both. The per-section control Bing added in October 2025 is `data-nosnippet`: content inside a `data-nosnippet` element is still crawled, still indexed and still available for ranking, and is excluded from snippets and AI summaries. That is the granular tool. Blocking Bingbot is not. Bing is also the only vendor here that offers hourly crawl rate control in a dashboard rather than through robots.txt, and it tells you outright that user agent strings are easy to spoof and points at its Verify Bingbot tool. ![Bots documented by OpenAI, Anthropic, Perplexity and Common Crawl](https://adityaarsharma.com/wp-content/uploads/2026/09/759d2990-56ef-4436-84da-7779b2ae3755_2912x1632-scaled.png)Counted from each vendor's own crawler documentation, read 2 September 2026. ## Count what is actually hitting you first Before writing a single rule, get the base rate from your own access log. This runs on a standard combined log format and is case-insensitive, which matters because Bing's token is lowercase in the real string: `awk ' BEGIN{ n=split("gptbot oai-searchbot oai-adsbot chatgpt-user claudebot claude-user \ claude-searchbot ccbot perplexitybot perplexity-user bingbot applebot \ google-cloudvertexbot meta-externalagent bytespider", B, " ") } { l=tolower($0); for(i=1;i<=n;i++) if(index(l,B[i])) { c[B[i]]++; break } } END{ for(k in c) printf "%-22s %d\n", k, c[k] }' /var/log/nginx/access.log | sort -k2 -nr` ### Verify before you trust the count Then verify before you trust the count. Every string above is trivially forgeable, and scrapers forge them precisely because site owners write allow rules against them. OpenAI, Perplexity and Common Crawl publish IP ranges as JSON; Google and Bing offer reverse DNS verification and a lookup tool. A user agent match on its own is a claim, not evidence. I have [watched a compromised site serve different content based on the user agent header](https://adityaarsharma.com/the-scanner-said-clean-the-site-was-serving-spam-to-googlebot/), which is the same trick pointed the other way. ## The four ways these blocks go wrong - **The un-blocking group.** An AI agent group containing only `Allow: /` cancels every Disallow in your `*` group for those agents. Repeat the general rules inside the specific group, or use `Disallow:` with an empty value if what you meant was full permission.- **Blocking the wrong OpenAI bot.** GPTBot is training. OAI-SearchBot is search. Blocking GPTBot to stay out of ChatGPT answers does nothing; blocking OAI-SearchBot removes you from them.- **Expecting Google-Extended to remove you from AI Overviews.** It does not, by Google's own statement. Use Search Console's generative AI control, or snippet controls.- **Believing robots.txt is a wall.** Two vendors document that their user-initiated fetchers may ignore it. If you need enforcement, you need it at the edge, and then you have to maintain IP lists that change. My [Cloudflare rules for blocking scrapers](https://adityaarsharma.com/how-to-protect-emails-scraping-from-spam-bots-in-wordpress/) are the same shape of problem.The related trap on WordPress is crawl waste rather than crawl blocking: parameterised URLs multiplying into thousands of near-identical pages that every one of these bots will happily fetch. That is worth fixing first, and I wrote up the WooCommerce version of it in [stopping add-to-cart URLs from being crawled](https://adityaarsharma.com/how-to-prevent-woocommerce-add-to-cart-dynamic-urls-from-crawling/). Open your own robots.txt and check one thing: does your AI agent group repeat the Disallow lines from your `*` group? If it does not, your AI crawlers have more access than your search crawlers, and you have been telling people the opposite. ## More on ai search and wordpress - [llms.txt for WordPress: What the Spec Actually Says and Whether Anything Reads It](https://adityaarsharma.com/llms-txt-wordpress-spec-and-adoption/)- [Structured Data and AI Answers: What Schema Is Actually Doing for You Now](https://adityaarsharma.com/structured-data-ai-answers-what-schema-does/)- [How to Measure Whether AI Search Sends You Anything](https://adityaarsharma.com/measure-ai-search-traffic-wordpress/) ## Resources - [RFC 9309, Robots Exclusion Protocol](https://www.rfc-editor.org/rfc/rfc9309.html). Section 2.2.1 is the group matching rule.- [OpenAI crawler documentation](https://platform.openai.com/docs/bots), plus [gptbot.json](https://openai.com/gptbot.json) and [searchbot.json](https://openai.com/searchbot.json).- [Anthropic crawler policy](https://support.anthropic.com/en/articles/8896518-does-anthropic-crawl-data-from-the-web-and-how-can-site-owners-block-the-crawler).- [Perplexity crawler documentation](https://docs.perplexity.ai/guides/bots), including the WAF configuration notes.- [Common Crawl FAQ](https://commoncrawl.org/big-picture/frequently-asked-questions/).- [Google common crawlers and fetchers](https://developers.google.com/search/docs/crawling-indexing/google-common-crawlers) and [Bing crawler overview](https://www.bing.com/webmasters/help/which-crawlers-does-bing-use-8c184ec0). --- # MCP servers for WordPress: what actually exists and what each one can do Source: https://adityaarsharma.com/mcp-servers-for-wordpress-what-exists/ The WordPress MCP server with the most stars on GitHub is read-only. Automattic archived `Automattic/wordpress-mcp` on 19 January 2026. It sits at 941 stars and 139 forks with a banner across the top of the page telling you to go somewhere else. I checked the repository on 2 September 2026 and the banner is still the first thing you see. ![I checked every WordPress MCP server that actually exists.](https://adityaarsharma.com/wp-content/uploads/2026/09/240ec83b-63b4-47a9-bb62-f2cdc75feb12_2912x1632-scaled.png)The notice gives two reasons and both of them matter more than the plugin did. The Abilities API was moving into WordPress core in 6.9. And `WordPress/mcp-adapter` had become the canonical package and Composer dependency for MCP in WordPress. Which means that if you followed one of the many tutorials that tell you to POST JSON-RPC at `/wp/v2/wpmcp/streamable`, you built on a plugin whose last commit landed on 30 August 2025. Here is what is actually maintained as of 2 September 2026, what each one can do, and where the boundary sits. ## The line that splits this whole category Every server here sits on one side of a single line: it either runs inside WordPress as a plugin and speaks MCP directly, or it runs outside WordPress as a Node process and speaks the REST API on your behalf. ### Inside the site, or outside it Inside means the tools are PHP callbacks with `current_user_can()` in front of them, and they can reach anything PHP can reach. Outside means every tool is an HTTP request carrying an application password, and anything the REST API cannot do, the server cannot do either. That one distinction predicts most of the differences below, including the ones people file as bugs. If you have not set up the surrounding stack yet, the wiring for wp-cli over SSH, a PHP bridge, Playwright and a crawler is in [my writeup on running Claude Code against WordPress](https://adityaarsharma.com/running-claude-code-against-wordpress-the-complete-setup/). This post is only about the MCP layer. ## WordPress/mcp-adapter: the official one | Field | Value | | ----- | ----- | | Repository | github.com/WordPress/mcp-adapter | | Licence | GPL-2.0-or-later (from `composer.json`) | | Latest tagged release | v0.6.1 | | Stars / forks | 1.7k / 191 | | Activity | 152 commits, most recent 2 September 2026 | | Runs | Inside WordPress, as a plugin or a Composer package | ### What the adapter does ![WordPress/mcp-adapter repository page on GitHub](https://adityaarsharma.com/wp-content/uploads/2026/09/396ae300-d124-4784-b5af-fd491d461b29_2880x1800-scaled.png)github.com/WordPress/mcp-adapter, screenshot taken 3 September 2026.The adapter does one job: it takes abilities registered through the WordPress Abilities API and exposes them as MCP tools, resources and prompts. It ships HTTP and STDIO transports, a `McpTransportInterface` for custom ones, and pluggable error and observability handlers. ### Abilities are private by default The part people trip over is the default. WordPress abilities are private. A fresh install of the adapter exposes almost nothing until you set `meta.public` (or `meta.mcp.public`) to `true` on each ability you want reachable. The adapter's default server then gives an agent three meta-tools rather than one tool per ability: - `mcp-adapter/discover-abilities`- `mcp-adapter/get-ability-info`- `mcp-adapter/execute-ability` ![A fresh mcp-adapter install exposes three tools](https://adityaarsharma.com/wp-content/uploads/2026/09/9ef4c4e0-e5fc-4340-b88e-261151fa5bca_2400x2400.png)discover-abilities, get-ability-info and execute-ability.That is a deliberate design and it is the right one for context budgets. A site with sixty registered abilities does not send sixty tool schemas into every request. The agent discovers, inspects, then executes. The cost is one extra round trip before the first useful call. ### The endpoint, and the transports The HTTP endpoint for the default server is `/wp-json/mcp/mcp-adapter-default-server`. Per-server transport authentication and per-ability permission checks are both configurable, which is the thing to read before you point anything at a live site. ## WordPress/abilities-api: the layer underneath The adapter is not much use without abilities to adapt, and that is a separate project which has now landed in core. `wp_register_ability()` lives in `wp-includes/abilities-api.php` and the core reference marks it as introduced in 6.9.0. It is core code now, not a plugin you install. Registration looks like this. Note the two callbacks and the annotations block, because those are the parts that decide what an agent is allowed to do: `wp_register_ability( 'my-plugin/analyze-text', array( 'label' => __( 'Analyze text', 'my-plugin' ), 'execute_callback' => 'my_plugin_analyze', 'permission_callback' => fn() => current_user_can( 'edit_posts' ), ) );`It hangs off `add_action( 'wp_abilities_api_init', ... )`. The full call also takes `input_schema` and `output_schema`, plus an `annotations` block carrying `readonly`, `destructive` and `idempotent`. The two callbacks and the annotations are the parts an agent host actually reads. ### Categories come first Categories have to be registered first, on `wp_abilities_api_categories_init`, with `wp_register_ability_category()`. Schemas use a subset of JSON Schema draft 4, the same validator the REST API uses. ### What core actually registers Now the thing worth checking yourself. Open `wp-includes/abilities.php` in a 6.9 or later install and count the abilities core registers. There are three: - `core/get-site-info`, permission callback `current_user_can( 'manage_options' )`- `core/get-user-info`, permission callback `is_user_logged_in()`- `core/get-environment-info`, permission callback `current_user_can( 'manage_options' )` All three carry `'readonly' => true`, `'destructive' => false`, `'idempotent' => true`. WordPress core's own agent surface, today, reads site metadata and nothing else. Every write your agent performs through this route comes from an ability that you or a plugin author wrote. That is not a criticism of the design. It is the fact people miss when they install the adapter and wonder why the agent cannot publish anything. ## One discrepancy worth knowing about The WordPress Developer Blog post that introduced the Abilities API in November 2025 documents the REST routes as `/wp-json/wp-abilities/v1/{namespace/ability}`. The controllers that shipped in core use a `rest_base` of `abilities`, so the real paths carry an extra segment: `# list every registered ability (needs the 'read' capability, so any logged-in user) GET /wp-json/wp-abilities/v1/abilities # inspect one GET /wp-json/wp-abilities/v1/abilities/core/get-site-info # execute one (the ability's own permission_callback still runs) POST /wp-json/wp-abilities/v1/abilities/core/get-site-info/run` Verified against `class-wp-rest-abilities-v1-list-controller.php` and `class-wp-rest-abilities-v1-run-controller.php` in `wordpress-develop` trunk on 2 September 2026, where `$namespace` is `wp-abilities/v1` and `$rest_base` is `abilities`. If a client of yours 404s on the documented path, that is why. ### The listing permission The listing permission is also worth a second look: `get_items_permissions_check()` returns `current_user_can( 'read' )`. A subscriber-level application password can enumerate every registered ability on the site, including their descriptions and input schemas. Running them is gated separately, per ability. ## Automattic/wordpress-mcp: archived, and still the one in most tutorials | Field | Value | | ----- | ----- | | Repository | github.com/Automattic/wordpress-mcp (archived 19 Jan 2026) | | Licence | GPL-2.0-or-later | | Last version in the tree | 0.2.5 | | Stars / forks | 941 / 139 | | Runs | Inside WordPress, as a plugin | ### Why the archived one still matters ![GitHub stars across four WordPress MCP servers](https://adityaarsharma.com/wp-content/uploads/2026/09/e4410059-a8ed-4da1-8354-b6a6d383571d_2912x1632-scaled.png)GitHub stars, read on the dates given below each project.It is worth understanding because a lot of running installs still have it. Two transports: `/wp/v2/wpmcp` in a WordPress-shaped format, and `/wp/v2/wpmcp/streamable` speaking JSON-RPC 2.0. The first accepted JWT and application passwords, the second accepted JWT only. Tokens were generated in the admin with a duration of one to twenty-four hours. Its most interesting feature was also its most dangerous, and it was labelled experimental in the README. Turning on REST API CRUD tools gave the agent three generic tools: `list_api_functions`, `get_function_details` and `run_api_function`. That last one executes any WordPress REST API route the current user has permission for. The plugin let you disable create, update and delete tools individually and excluded a small set of routes such as JWT auth, oembed, autosaves and revisions. ### Generic-execute tools Generic-execute tools are the pattern to think hardest about. They give an agent the entire REST surface of every plugin on the site in one tool, which means your blast radius is now defined by whichever plugin registered the most careless route. If you are running that pattern today, the exposure question is the same one I work through in [the post about a malware scanner that reported clean while the site served spam to Googlebot](https://adityaarsharma.com/the-scanner-said-clean-the-site-was-serving-spam-to-googlebot/): what the tool reports and what is actually reachable are two different sets. ## Automattic/mcp-wordpress-remote: the proxy Published on npm as `@automattic/mcp-wordpress-remote`, version 0.4.0, MIT licensed. It is a client-side bridge that runs on your machine over STDIO and talks HTTP to the site, which is how you connect a client that does not speak remote MCP transports. ### What the README points at now Its README now uses `/wp-json/mcp/mcp-adapter-default-server` as the example `WP_API_URL`, which is the clearest signal that the migration to the adapter is real rather than announced. It implements the MCP authorization specification dated 2025-06-18, OAuth 2.1 with PKCE, resource indicators per RFC 8707, dynamic client registration per RFC 7591 and protected resource metadata discovery per RFC 9728, alongside JWT and application passwords. The client config is a standard `mcpServers` entry: command `npx`, args `["-y", "@automattic/mcp-wordpress-remote"]`, and an `env` block carrying `WP_API_URL`, `WP_API_USERNAME` and `WP_API_PASSWORD`. ## mcp-wp/mcp-server and mcp-wp/ai-command: the WP-CLI route Pascal Birchler's `mcp-wp/mcp-server` is Apache-2.0, 61 stars, and takes a different approach: it wraps `logiscape/mcp-sdk-php` and exposes a server at `wp-json/mcp/v1/mcp`. Read the README before you deploy it. It says plainly that the Streamable HTTP transport is not fully implemented, that there are no tests, and that it might not work as expected. That is the kind of honesty you should reward by believing it. ### The WP-CLI companion The companion is `mcp-wp/ai-command`, 121 stars, a WP-CLI package that turns your terminal into an MCP host: `wp package install mcp-wp/ai-command:dev-main wp plugin install --activate ai-services wp mcp server add "mysite" "https://example.com/wp-json/mcp/v1/mcp" wp ai "Greet my friend Pascal"` It needs WP-CLI 2.11 or greater and the AI Services plugin. The README notes the command also works against a local WordPress install with no server plugin at all, which makes it the cheapest way to feel what this category does before you expose anything over HTTP. ## docdyhr/mcp-wordpress: the third-party Node server | Field | Value | | ----- | ----- | | Package | `mcp-wordpress` on npm, version 3.3.33 | | Licence | MIT | | Author | Thomas Dyhr | | Stars | 106 | | Runs | Outside WordPress, TypeScript, over the REST API | Its own `package.json` describes 71 tools, an SEO toolkit, performance monitoring and caching. Authentication is a WordPress application password, and it manages multiple sites from one process. Because it sits outside WordPress and speaks REST, it inherits every REST API limit, including the ones that surprise people. Which is the subject of the next post in this cluster. ## WordPress/ai: not a server, worth knowing about The `WordPress/ai` canonical plugin is the reference implementation that combines the building blocks: the PHP AI Client SDK, the Abilities API and the adapter. Its own README calls it experimental and says features may change, move or break, and recommends against production. It is block-editor only by explicit project decision, with no Classic Editor support planned. ### The abilities browser What makes it useful even if you never ship it is the abilities browser: a screen that discovers, inspects, tests and documents every ability registered on the site. That is the fastest way to see what your plugins are actually exposing to an agent, which is a question most people have never asked of their own site. If you are tracking where WordPress is heading with all of this, I covered the wider shift in [the piece on WordPress 7.0](https://adityaarsharma.com/wordpress-7-0-is-the-biggest-thing-to-happen-since-gutenberg-and-nobodys-talking-about-it/). ## What to actually pick - Building abilities into a plugin you own: `wp_register_ability()` in core, plus `WordPress/mcp-adapter` when you need MCP transport. This is the route with a future.- Driving an existing site you did not build: a REST-based server such as `docdyhr/mcp-wordpress`, or wp-cli over SSH, because there are no abilities registered to adapt.- Local development: `wp ai` from `mcp-wp/ai-command`, no plugin required.- Already running `Automattic/wordpress-mcp`: plan the move. It is archived, and the generic `run_api_function` tool is the widest thing on your site. ### What star counts do not tell you One caveat I will not paper over. Star counts and release tags tell you a project is alive. They do not tell you it survives a WordPress site with a decade of plugins on it. I have not benchmarked these servers against each other on the same install, and I have not seen anyone publish that comparison. Treat everything above as a map of what exists and what its own maintainers claim, verified against their repositories on 2 September 2026, and not as a performance ranking. ## The one thing to do next Install the Abilities API on a staging copy, hit `GET /wp-json/wp-abilities/v1/abilities` with a subscriber-level application password, and read what comes back. If the list is longer than the three core abilities, some plugin on that site has already opened a door and you did not choose it. That is a five-minute check and it changes what you do next. For how the underlying HTTP surface behaves once you start writing through it, including which capability each route demands and what is not automatable at all, the companion post on the REST API as an agent surface goes route by route. And if the thing you are actually trying to solve is where an agent keeps what it learned between sessions, that is a different problem, covered in [the comparison of AI memory tools](https://adityaarsharma.com/ai-memory-tools-compared/). ## Resources - [WordPress/mcp-adapter](https://github.com/WordPress/mcp-adapter) (GPL-2.0-or-later)- [WordPress/abilities-api](https://github.com/WordPress/abilities-api)- [wp_register_ability() in the WordPress code reference](https://developer.wordpress.org/reference/functions/wp_register_ability/)- [Introducing the WordPress Abilities API, WordPress Developer Blog, November 2025](https://developer.wordpress.org/news/2025/11/introducing-the-wordpress-abilities-api/)- [Automattic/wordpress-mcp](https://github.com/Automattic/wordpress-mcp) (archived 19 January 2026)- [Automattic/mcp-wordpress-remote](https://github.com/Automattic/mcp-wordpress-remote)- [mcp-wp/mcp-server](https://github.com/mcp-wp/mcp-server) and [mcp-wp/ai-command](https://github.com/mcp-wp/ai-command)- [docdyhr/mcp-wordpress](https://github.com/docdyhr/mcp-wordpress)- [WordPress/ai canonical plugin](https://github.com/WordPress/ai)- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/2025-11-25)- [AI Building Blocks for WordPress initiative](https://make.wordpress.org/ai/2025/07/17/ai-building-blocks) Disclosure: I work on product and growth at POSIMYTH, which ships WordPress plugins across 500,000+ installs. None of the projects above are ours, and none of them paid for a mention. ## More on agents on wordpress - [The WordPress REST API as an agent surface: what you can and cannot automate](https://adityaarsharma.com/wordpress-rest-api-agent-surface/)- [Guardrails for running an agent against a production WordPress site](https://adityaarsharma.com/guardrails-agent-production-wordpress/)- [Automating WordPress maintenance with agents: what is worth automating and what is not](https://adityaarsharma.com/automating-wordpress-maintenance-with-agents/) --- # A CDN for WordPress: What Actually Helps, and the Layer My Own Site Is Failing Source: https://adityaarsharma.com/cdn-for-wordpress-what-actually-helps/ My homepage takes 1.0 seconds to start arriving. A stylesheet on the same domain takes 0.22 seconds. Both come from the same Cloudflare edge, both are cached, and the difference between them is the entire subject of this post. Five runs each, measured this morning, 3 September 2026: ![The edge serves my CSS in 0.22s and refuses to touch my HTML.](https://adityaarsharma.com/wp-content/uploads/2026/09/783c0f07-6e7b-42c3-a401-b961cbb6a360_2912x1632-scaled.png)`run1 ttfb=1.025484 x-runcloud-cache: HIT cf-cache-status: DYNAMIC run2 ttfb=0.995375 x-runcloud-cache: HIT cf-cache-status: DYNAMIC run3 ttfb=0.997329 x-runcloud-cache: HIT cf-cache-status: DYNAMIC run4 ttfb=1.055049 x-runcloud-cache: HIT cf-cache-status: DYNAMIC run5 ttfb=1.137921 x-runcloud-cache: HIT cf-cache-status: DYNAMIC`That is five runs of `curl -w "%{time_starttransfer}"` against my own homepage, three seconds apart, grepping the two cache headers out of the response. Three runs against a cached stylesheet came back `cf-cache-status: HIT` with `cache-control: max-age=2592000` and a rising `age`, at 0.226s, 0.235s and 0.219s. `x-runcloud-cache: HIT` on every HTML request means my origin's page cache is working perfectly. PHP barely ran. WordPress barely ran. And it still took a second, because `cf-cache-status: DYNAMIC` means Cloudflare never looked in its own cache and went straight to my origin server, every single time, for a file whose contents had not changed. This is the layer my site is failing. I said as much when I measured [what actually makes my site slow](https://adityaarsharma.com/what-actually-makes-a-wordpress-site-slow/) and concluded the slow part was not WordPress. Here is the part I did not do then, which is work out exactly why the edge refuses to help, and what would have to be true before I could safely make it. ## Why DYNAMIC is the default, in Cloudflare's own words ![Cloudflare docs page: Default cache behavior](https://adityaarsharma.com/wp-content/uploads/2026/09/0d2f5731-309c-4310-81f2-50364398fbca_2880x1800-scaled.png)developers.cloudflare.com, Default cache behavior, last updated 14 August 2026. Screenshot taken 3 September 2026.The reason is one sentence in Cloudflare's default cache behaviour documentation, last updated 14 August 2026: > Cloudflare only caches based on file extension and not by MIME type. The Cloudflare CDN does not cache HTML or JSON by default. > > Cloudflare docs, Default cache behavior, retrieved 3 September 2026 ### The extension list, and what is missing from it The page lists the extensions that *are* cached by default, and it is a long list: 7Z, AVI, AVIF, BMP, CSS, DOC, EOT, GIF, GZ, ICO, JPG, JPEG, JS, JSON is absent, MP3, MP4, OTF, PDF, PNG, SVG, TTF, WEBM, WEBP, WOFF, WOFF2, ZIP and about thirty more. Notice what that list is: assets. Not pages. So the decision is made on the URL, before the request is even sent to your origin. Cloudflare's own definition of the status makes the ordering explicit: > DYNAMIC: Cloudflare determined at request time that the asset is not eligible for cache, so the request went to the origin web server without a cache lookup. > > Cloudflare docs, Cloudflare cache responses, retrieved 3 September 2026 ### DYNAMIC is not a miss *Without a cache lookup.* That phrase is the whole mechanism. DYNAMIC is not a miss. A miss means the edge looked and found nothing, which is why the next request can be a hit. DYNAMIC means the edge did not look and will never look, so the thousandth identical request costs exactly what the first one did. My `cache-control: public, max-age=300, stale-while-revalidate=86400` header is being ignored, not because Cloudflare disrespects it, but because the URL never got as far as the code that reads it. ## Decomposing the second With three measurements you can split that 1.0 second into named parts. First, the edge serving a cached static file, which is the floor: ### Part one: the edge serving a cached file Three runs on a cached static file: `cf-cache-status: HIT` at 0.219s, 0.226s and 0.235s. Second, the same static file with a random query string appended, which forces Cloudflare to fetch it from my origin. No PHP, no MySQL, no WordPress. Just nginx opening a file: ### Part two: the edge fetching from origin The command was the same loop against a static asset with `?b=$RANDOM$RANDOM` appended to defeat the edge cache. Four runs came back `cf-cache-status: MISS` at 0.510s, 0.588s, 0.513s and 0.525s. ![Time to first byte for HTML, a static file miss and a static file hit](https://adityaarsharma.com/wp-content/uploads/2026/09/8f4abafc-c5f5-4c42-8cd7-34bce680f33a_2912x1632-scaled.png)Measured with curl against adityaarsharma.com, 2 September 2026.Third, the HTML, at 1.0 seconds. Subtract, and the picture is unambiguous: - **0.22s** is my client reaching the Cloudflare edge and being served from its memory. Pure network.- **+0.29s** is the edge reaching my origin and back for a static file. That is the origin round trip, and it is a distance problem, not a software problem.- **+0.47s** is what my origin adds for HTML over a static file, even with the page cache reporting HIT. ### The uncomfortable number The last number is the uncomfortable one and I am not going to paper over it. A page-cache HIT is not free: nginx still has to match the request against the cache rules, check the cookie exclusions, look up the cached entry and hand back roughly 172 kilobytes rather than 679 bytes. Nearly half a second of it, though, is more than that accounting explains, and I do not have a clean measurement of where it goes. I can measure the outside of the box precisely and I am guessing about the inside, so I am saying so. What the decomposition does establish, without any guessing: if Cloudflare served that HTML from the edge, the 0.29s origin trip and the 0.47s origin work both disappear. The page would start arriving in roughly 0.22 seconds instead of 1.0. That is a Largest Contentful Paint improvement of three quarters of a second before a single byte of the page is optimised, which is a bigger number than anything else on my performance list. [The Core Web Vitals thresholds](https://adityaarsharma.com/core-web-vitals-wordpress-2026/) are close enough on this site that this one change would move the grade. ## What has to be true before HTML at the edge is safe This is where most guides stop and just tell you to turn on Cache Everything. Do not, until you can answer all four of these, because a shared HTML cache serves one user's page to another user by design. ### 1. Logged-in users must never get a cached page **1. Logged-in users must never receive a cached page.** WordPress signals a session with cookies whose names begin `wordpress_logged_in_`, `wp-postpass_` and `comment_author_`. My origin already handles this correctly. I checked, by sending the cookie: The check is one request with a fake session cookie: `curl -H 'Cookie: wordpress_logged_in_abc=test'` against the homepage, grepping for the two cache headers. `x-runcloud-cache: BYPASS`, and the response took 1.88 seconds instead of 1.0 because PHP genuinely ran. The origin is doing the right thing. The question is whether the edge would, and the answer with a naive Cache Everything rule is no, because a cache rule that ignores cookies will happily store the admin bar version of your homepage and serve it to the world. ![1.88 seconds to first byte with a WordPress session cookie](https://adityaarsharma.com/wp-content/uploads/2026/09/ccfb16d3-b6e9-464e-b28b-47e4cd68715b_2400x2400.png)The same homepage with a session cookie attached, 2 September 2026.The correct shape is a bypass rule evaluated *before* the cache rule, expressed in Cloudflare's rules language: `# Cache rule 1, priority 1: bypass when a session cookie is present (http.request.headers.cookie contains "wordpress_logged_in_") or (http.request.headers.cookie contains "wp-postpass_") or (starts_with(http.request.uri.path, "/wp-admin")) -> Cache eligibility: Bypass cache`The full rule also matches `comment_author_`, `woocommerce_items_in_cart` and `wp_woocommerce_session_` cookies, plus `/wp-login.php` and any path starting `/wp-json`. Cache rule 2, at priority 2, is the Cache Everything rule that only sees what rule 1 lets through. Order matters and it is the thing people get wrong. Cloudflare evaluates cache rules in order and the first match wins, so the bypass has to be first. Put the cache-everything rule first and the bypass never runs. ### 2. Carts and checkout, by path and by cookie **2. Carts and checkout must be excluded by path as well as by cookie.** A WooCommerce cart cookie is only set once something is in the cart. A visitor with an empty cart hitting `/checkout/` has no cookie yet, so a cookie-only rule caches the empty checkout page, and the next visitor gets it. Exclude `/cart/`, `/checkout/`, `/my-account/` and the AJAX endpoints by path regardless of cookies. WooCommerce also uses a fragments call for the cart count precisely because that number cannot be part of a cached page. ### 3. Nonces, and the bug nobody can reproduce **3. Nonces must not be cached.** This is the failure that produces bug reports nobody can reproduce. A WordPress nonce is generated from the user, the action, a session token and a time tick, and it is valid for a bounded window. Cache a page containing a form nonce for an hour and every visitor who submits that form after the nonce expires gets a security check failure. It is intermittent, it depends on when the cache filled rather than when the user arrived, and it looks like nothing when you test it yourself thirty seconds later. If a page contains a form for logged-out users, either exclude it or make the form fetch its nonce over AJAX at submit time. The same reasoning is why [application passwords and the REST API](https://adityaarsharma.com/wordpress-application-passwords-security-model/) sit outside anything cacheable. ### 4. Purge on publish **4. You need a purge on publish.** An edge TTL of one hour means an hour of stale content after every edit unless something calls Cloudflare's purge API. Most WordPress caching plugins with a Cloudflare integration do this on `save_post` and `transition_post_status`. Verify it rather than trusting it: edit a post, then curl the URL and look at the `age` header. If `age` keeps climbing across the edit, nothing purged. ## The one that is nearly free, and that almost nobody sets Before Cache Everything, there is a smaller change with none of the risk. My static assets carry `cache-control: max-age=2592000`, thirty days, and the `age` header on the CSS above was 64, 68 and 71 seconds across three requests three seconds apart. The edge copy was about a minute old and climbing normally, so that layer is behaving. ### One header for browsers, one for the edge What is not set is a separate edge lifetime. `cache-control` is a single instruction to two very different caches: a browser, which is one user's disk and can be wrong for as long as that user does not clear it, and a shared edge cache, which you can purge from an API in seconds. Splitting them is what `CDN-Cache-Control` is for, and Cloudflare's documentation on BYPASS spells out the precedence: `Cloudflare-CDN-Cache-Control` beats `CDN-Cache-Control`, which beats `Cache-Control`. In nginx, match the static extensions in a `location ~* \.(css|js|woff2|jpg|jpeg|png|webp|avif|svg)$` block and add two headers: `Cache-Control "public, max-age=604800"` for browsers, seven days, and `CDN-Cache-Control "public, max-age=31536000"` for the edge, a year. A week in the browser so a returning visitor gets an update within days, a year at the edge because you can purge it whenever you deploy. This costs nothing, breaks nothing, and needs none of the four conditions above. ## What does not work **Turning on Cache Everything and hoping.** Without the cookie bypass ordered first, you will eventually serve a logged-in page to an anonymous visitor. That is a data exposure, not a performance regression, and you will find out from a customer. ### Development Mode is not a test **Using Development Mode as a test.** Development Mode bypasses the edge cache entirely, so testing in it tells you exactly nothing about what the edge will do afterwards. Test with a cache rule scoped to a single URL path instead. ### Your plugin dashboard is not evidence **Believing your plugin's dashboard.** Every caching plugin has a screen that says the cache is working. Mine says so, and it is telling the truth about the layer it owns while the layer above it is doing nothing. The only reliable test is a header from outside, which is the whole method in [the post on which caching layer is actually failing](https://adityaarsharma.com/wordpress-caching-layers-which-one-is-failing/). **Assuming the edge is close to your visitors and far from your origin.** Check. My Cloudflare colo is in Singapore, from the `cf-ray` suffix on every response, and the origin trip from there costs about 0.29 seconds. If most of your traffic is in one country and your origin is in that country, edge-caching HTML buys you less than it buys me. [Reading your own access logs](https://adityaarsharma.com/reading-wordpress-access-logs/) will tell you where your traffic is before you optimise for where you assume it is. ## Do this in the next ten minutes Run one command against your own site and read one header: `curl -s -o /dev/null -D - https://YOURSITE.com/ \ -w "\nttfb=%{time_starttransfer}\n" | grep -iE 'cf-cache-status|age:|cache-control'`If `cf-cache-status` says `DYNAMIC`, your HTML is going to your origin on every request and you have a Cache Everything decision to make. If it says `HIT`, log in and run it again with your session cookie, and make very sure it says `BYPASS`. That second check is the one that matters, and it takes fifteen seconds. ## Resources - [Cloudflare: Default cache behavior](https://developers.cloudflare.com/cache/concepts/default-cache-behavior/), including the full default cached file extension list and the line about HTML. Last updated 14 August 2026.- [Cloudflare: cache responses](https://developers.cloudflare.com/cache/concepts/cache-responses/), the normative definitions of HIT, MISS, BYPASS, DYNAMIC, REVALIDATED and UPDATING. Last updated 21 August 2026.- [Cloudflare: Cache Rules](https://developers.cloudflare.com/cache/how-to/cache-rules/), where the bypass and cache-everything rules above are configured.- [Cloudflare: CDN-Cache-Control](https://developers.cloudflare.com/cache/concepts/cdn-cache-control/), and the precedence order between the three cache control headers.- [WordPress nonces in the developer handbook](https://developer.wordpress.org/apis/security/nonces/), for why a cached page containing a form is a problem. --- # Choosing an AI Memory Layer: Sort Them by What Comes Back, Not by Stars Source: https://adityaarsharma.com/best-ai-memory-layer-for-agents/ `letta-ai/letta` has 24,587 stars. Its README is 1,942 bytes long and it says the repository is now a landing page. The code moved to `letta-ai/letta-code`, which has 3,190 stars. The last release tagged on the repo with the stars was 0.16.8 on 14 May 2026. The last release on the repo with the code was v0.31.11 on 1 September 2026. Both figures came from the GitHub API on 3 September 2026. ![The most-starred Letta repository is a 1,942-byte landing page. The code is somewhere else.](https://adityaarsharma.com/wp-content/uploads/2026/09/910a7c4f-ff18-405a-adfb-40521c46fa33_2912x1632-scaled.png)That is the problem with picking a memory layer by looking at a list. ![letta-ai/letta repository page on GitHub showing 24.6k stars and release v0.16.8](https://adityaarsharma.com/wp-content/uploads/2026/09/016969b6-f9ca-450b-824d-021b15453b56_2880x1800-scaled.png)letta-ai/letta on GitHub, screenshot taken 3 September 2026. 24.6k stars, latest release v0.16.8.The list ranks by stars, stars accumulate on the URL people bookmarked in 2024, and the URL people bookmarked is not always where the software is. So this is not a ranking. ![GitHub stars across eight AI memory projects](https://adityaarsharma.com/wp-content/uploads/2026/09/5bb4f667-b118-4edc-8d51-87c577e7c682_2912x1632-scaled.png)GitHub stars across the field, read from the GitHub API on 3 September 2026.It is one axis, applied consistently, with the tools sorted on it. ## The axis: what comes back when you ask Every system in this category claims persistence. All of them persist. That question does not separate anything. The question that separates them is what the retrieval unit is, meaning the shape of the thing that lands back in your context window. There are four answers in the field right now, and picking between them is the actual decision: - **A model-written summary of what you said.** Something extracted your sentence into a fact and stored the fact.- **Your original text, unchanged.** The system stored the sentence and gives you the sentence.- **A file you can open in an editor.** The unit is a Markdown note that exists on disk with a name.- **A subgraph or a path.** The unit is a set of nodes and edges, and you get relationships rather than prose. ### Why the four are not interchangeable These are not interchangeable. If you pick a summariser and later need the exact wording, it is gone. If you pick a traversal system and ask it a question that has no structural answer, you get nothing useful. Choose the unit first, then the vendor. ## Sorted on that axis All facts below are from each project's own README or documentation, read on 3 September 2026, with GitHub metadata from the API on the same day. ### 1. Returns a model-written summary **mem0** (64,601 stars, Apache-2.0, last release ts-v3.1.8 on 2 September 2026). Its README states that mem0 requires a large language model to function, with `gpt-5-mini` as the default, and uses `text-embedding-3-small` as the default embedding model. ### The default vector store The vector database documentation states that when no configuration is supplied, Qdrant is used. So the write path is: your text goes to a model, the model produces a memory, the memory is embedded, the embedding goes to Qdrant. ### Supermemory, the same class **Supermemory** (29,199 stars, MIT, last release server-v0.0.8 on 17 August 2026) sits in the same class. Its README says it automatically extracts memories and builds user profiles, and explicitly frames that as the point: no embedding pipelines, no vector database configuration, no chunking strategies to choose. ### What you gain, and what you cannot get back What you gain is compression. A thousand messages become a few dozen facts, and the retrieval is short. What you lose is that the model decided what mattered. If it drops the caveat in your sentence, the caveat is not recoverable, because the original was never the stored unit. And you pay a model call at write time, every write. I priced what that arithmetic looks like at volume in [the breakdown of what self-hosted AI memory costs to run](https://adityaarsharma.com/self-hosted-ai-memory-cost/). ### 2. Returns your original text **MemPalace** (58,808 stars, MIT, last release v3.9.0 on 31 August 2026). Its README is unusually direct about the mechanism: it stores conversation history as verbatim text and retrieves it with semantic search, and it does not summarise, extract or paraphrase. ### How the index is shaped The index is structured rather than flat, with people and projects as wings, topics as rooms, and original content in drawers, so a search can be scoped. The retrieval unit is the drawer, and the drawer holds what you actually wrote. That is the correct choice when the wording is the information, which is more often than people expect: a decision with its reasoning, a client's exact requirement, a paragraph you liked. ### Where verbatim storage costs you The cost shows up as volume. Verbatim storage grows with your history rather than with the number of distinct facts you hold. The default retrieval backend is ChromaDB, embedded and local, with a bundled `sqlite_exact` alternative, and Milvus, Qdrant and pgvector available as opt-in server backends. Embeddings run locally by default, roughly 30 MB for the English-only MiniLM model or about 300 MB for the multilingual EmbeddingGemma option, per the README. ### 3. Returns a file you can open **Basic Memory** (3,841 stars, AGPL-3.0, last release v0.23.2 on 25 August 2026). The smallest star count on this page and the clearest storage model on it. ### Files on disk plus a SQLite index Its README describes the store as plain Markdown files on your disk plus a local SQLite index, with no servers required. Each file is an entity with observations and relations, and the relations are wiki-style double-bracket links. `--- title: Coffee Brewing Methods permalink: coffee-brewing-methods tags: [coffee, brewing] --- ## Observations - [method] Pour over gives the cleanest cup ## Relations - pairs_with [[Grinder Settings]]` ### You can fix a wrong memory by editing a file The reason this matters more than it sounds: when the retrieval unit is a file, you can fix a wrong memory by editing a file. In every summariser, correcting a bad memory means asking the system to correct it and hoping. Here you open the note in Obsidian and change the line. The README says Obsidian needs no setup at all: point it at the project folder and the same wikilinks and frontmatter appear in its graph view. ### The trade The trade is that you are now maintaining a knowledge base. Files accumulate, links go stale, and nobody prunes them. A summariser at least has an opinion about what to keep. ### 4. Returns a subgraph or a path **Graphiti** (30,536 stars, Apache-2.0, last release mcp-v1.1.0 on 1 September 2026) stores facts as triplets with temporal validity windows, plus the raw episodes that produced them. ### Facts that expire Its documented behaviour when information changes is that old facts are invalidated rather than deleted, so you can query what is true now or what was true at a point in time. That is the only system here whose retrieval unit carries its own expiry. ### The database you have to keep running The operational cost is real and stated in the README: it needs a graph database. Neo4j 5.26, FalkorDB 1.1.2, Amazon Neptune with OpenSearch Serverless, or Kuzu 0.11.2 which the README marks deprecated because the upstream project is no longer maintained. It also defaults to OpenAI for both inference and embeddings and expects an API key. ### Cognee, and the API key requirement **Cognee** (30,426 stars, Apache-2.0, last release v1.5.3 on 23 August 2026) is the same shape with lighter defaults: the documentation lists Ladybug/Kuzu as the default graph store and LanceDB as the default vector store, with SQLite relational, all embedded for local development. It requires `LLM_API_KEY` to index. If that constraint is what is pushing you away from it, I went through the actual replacements in [the piece on what you are actually replacing when you leave Cognee](https://adityaarsharma.com/cognee-alternatives/). **LightRAG** (39,349 stars, MIT, last release v1.5.7 on 2 September 2026) and the two code-graph tools, codebase-memory-mcp and Graphify, also sit here. The code-graph pair is a different job entirely, and I compared them directly in [the codebase-memory-mcp and Graphify comparison](https://adityaarsharma.com/codebase-memory-mcp-vs-graphify/). ## What running each one actually costs you in attention Price is the wrong first question, because at personal scale all of these are cheap and at production scale the model calls dominate everything else. The better question is how many moving parts you have agreed to keep alive. | Retrieval unit | Example | Services you must run | Model call at write time | | -------------- | ------- | --------------------- | ------------------------ | | Model summary | mem0 | A vector store (Qdrant by default) | Yes, gpt-5-mini by default | | Verbatim text | MemPalace | None. ChromaDB is embedded | No. Local embeddings by default | | Markdown file | Basic Memory | None. Files plus SQLite | No for writing. Optional for semantic search | | Graph triplet | Graphiti | Neo4j, FalkorDB or Neptune | Yes, OpenAI by default | | Graph triplet | Cognee | None locally. Kuzu and LanceDB embedded | Yes, LLM_API_KEY required | ### Reading the last two columns ![1,942 bytes of README on a 24,587 star repository](https://adityaarsharma.com/wp-content/uploads/2026/09/1c53254c-7f87-4cb4-8b7f-bb5566817a42_2400x2400.png)The README that told me Letta had moved. GitHub API, 3 September 2026.Read the last two columns together. The systems with no service to run and no model call at write time are the ones you will still be using in six months, because there is nothing to forget to restart and no bill that grows while you sleep. That is not an argument that they retrieve better. It is an argument that the failure mode of the others is abandonment. ## The three checks that beat any comparison post, including this one Every list in this category, mine included, is a snapshot. Here is how to re-derive it in under a minute per project. `R=getzep/graphiti # or whichever curl -s https://api.github.com/repos/$R | \ python3 -c "import json,sys;d=json.load(sys.stdin);print(d['pushed_at'],d['archived'],(d['license'] or {}).get('spdx_id'))" curl -s https://api.github.com/repos/$R/releases/latest | \ python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('tag_name'),d.get('published_at'))"`Three things to read off that output. - **Compare `pushed_at` with the latest release date.** A repo pushed yesterday with no release in five months is being worked on but not shipped. Khoj is the clean example: pushed 2 August 2026, latest published release 2.0.0-beta.28 dated 26 March 2026.- **Read the README's own length.** A 1,942-byte README on a 24,587-star project told me Letta had moved before any article did.- **Search the README for the word 'key'.** If an API key is required to index, the system has an ongoing cost and a network dependency whatever else it claims about being local. ## So which one If you want your own words back, MemPalace. If you want to be able to open and correct what the agent remembered, Basic Memory. If you need to answer questions about when something stopped being true, Graphiti, and accept that you are now running a graph database. If you want the shortest possible retrieval and do not mind a model deciding what mattered, mem0. If you are still deciding whether you need a memory layer at all rather than retrieval over documents, start one step earlier with [the difference between an MCP server and a RAG pipeline](https://adityaarsharma.com/mcp-server-vs-rag/), because a good half of the traffic on this topic is asking a protocol question in architecture words. The full field with storage backends and licences is in [the platform comparison table](https://adityaarsharma.com/ai-memory-platform-comparison/), and the cluster hub is [the AI memory tools comparison](https://adityaarsharma.com/ai-memory-tools-compared/). One thing to do now: open whichever memory tool you already have installed, save a sentence with an unusual phrase in it, then search for it and see whether the sentence comes back or a summary of the sentence comes back. That single test tells you which of the four classes you are in, and it takes about two minutes. ## Resources - [mem0](https://github.com/mem0ai/mem0) - README and the default model configuration- [mem0 vector store documentation](https://docs.mem0.ai/components/vectordbs/overview) - the line stating Qdrant is the default- [MemPalace](https://github.com/MemPalace/mempalace) - storage backend table and verbatim storage claim- [Basic Memory](https://github.com/basicmachines-co/basic-memory) - the Markdown entity format- [Graphiti](https://github.com/getzep/graphiti) - graph database requirements and temporal fact handling- [Cognee](https://github.com/topoteretes/cognee) - default Kuzu and LanceDB backends- [letta-ai/letta-code](https://github.com/letta-ai/letta-code) - where the Letta source actually lives now- [GitHub REST API: releases](https://docs.github.com/en/rest/releases/releases) - for the release-date check above --- # AI Memory for Writers: What Survives the Round Trip Source: https://adityaarsharma.com/ai-memory-for-writers/ codebase-memory-mcp supports 162 languages. Every one of them is a programming language. Graphify's headline capability table lists the edges it resolves: `calls`, `imports`, `inherits`, `mixes_in`. mem0's README lists its use cases as customer support and healthcare. I read all three on 3 September 2026, and none of them mentions a manuscript, a draft, or a house style. ![codebase-memory-mcp supports 162 languages. Every one of them is a programming language.](https://adityaarsharma.com/wp-content/uploads/2026/09/3f53d10d-5226-4796-8294-e894c889a1af_2912x1632-scaled.png)That is the honest starting point for anyone searching for an AI memory system for writing. This category was built for code, by people solving a code problem, and the parts of it that work for prose work by accident. There is exactly one project I could find that was built for prose on purpose, and it is a skill file with 66 stars and no release. ## What a writer actually needs remembered Before picking a tool it is worth writing down what the memory is for, because the answer is different from a developer's answer and every tool in this field is optimised for the developer's answer. ### The six things a writer needs held A developer wants to know what calls what. A writer, across sessions, needs the model to hold: - **Spellings and names as decided.** Not approximately. The character is Elara, not Elera, and the company is styled lowercase.- **Facts already established in published text.** Once it is on the page it is load-bearing, and contradicting it is a rewrite.- **What is still open.** The setup you planted in chapter three and have not paid off.- **Who knows what, and when they learned it.** This is the single hardest thing to hold in your own head across a long piece.- **Voice decisions and their reasons.** We do not use semicolons here. We cut that metaphor because it was in the last piece.- **What was cut, and why.** Otherwise you rewrite the same rejected paragraph three months later. ### Mapping the six onto the four Look at that list against the four things memory systems return, which I sorted in [the piece on choosing a memory layer by retrieval unit](https://adityaarsharma.com/best-ai-memory-layer-for-agents/). Items one, five and six are ruined by summarisation, because the wording is the content. Items three and four are relationship questions, which is where a graph helps. Item two is a lookup. No single retrieval strategy covers all six, which is why the answer here is two tools rather than one. ## The rule that eliminates most of the field: does it store what you wrote ### What a summariser does to your sentence Here is the mechanism, and it is the whole decision. mem0's README states that it requires a language model to function, with `gpt-5-mini` as the default. The write path sends your text to that model and stores what the model produced. Supermemory's README says the same thing in marketing form: it automatically extracts memories and builds user profiles. For a developer, that compression is a feature. Your sentence about a retry policy becomes the fact 'retries are capped at three' and nothing of value is lost. ### Why that is destruction for prose For a writer it is destruction. You told it your protagonist speaks in short, clipped sentences because she is a lawyer who was trained never to give more than the question asked for. What comes back three sessions later is 'protagonist speaks tersely'. The reason is gone, so the model cannot apply it to a case you did not anticipate, and the phrasing is gone, so you cannot lift your own line back out. ### The one that promises the opposite ![GitHub stars on MemPalace, Khoj, basic-memory and graphify-novel](https://adityaarsharma.com/wp-content/uploads/2026/09/31a60e64-7737-491f-bb50-0436c22deeef_2912x1632-scaled.png)GitHub stars on the four projects, read from the GitHub API on 3 September 2026.MemPalace is the one project whose README makes the opposite promise in plain words: it stores conversation history as verbatim text and retrieves it with semantic search, and it does not summarise, extract or paraphrase. Read on 3 September 2026, MIT licence, 58,808 stars, last release v3.9.0 on 31 August 2026. That is the property to filter on. Not benchmark scores. Whether the sentence survives the round trip. ## The two setups that work without running a database ### Basic Memory: your notes are the memory `basicmachines-co/basic-memory`, 3,841 stars, AGPL-3.0, last release v0.23.2 on 25 August 2026. Its README describes the storage as plain Markdown files on your disk plus a local SQLite index, with no servers required. ### No server, no port ![basicmachines-co/basic-memory repository page on GitHub](https://adityaarsharma.com/wp-content/uploads/2026/09/6b20e9a0-486e-4ee0-9830-b78481225924_2880x1800-scaled.png)basicmachines-co/basic-memory on GitHub, screenshot taken 3 September 2026.SQLite is a file, so nothing is listening on a port and nothing needs restarting. The format is small enough to hold in your head, which for a writer is the entire point: `--- title: Elara Venn type: note permalink: elara-venn tags: [character, book-two] --- ## Observations - [voice] Short, clipped sentences under stress ## Relations - works_with [[Corvin Hale]]` ### Three things that follow from files Three things follow from files being the unit. You can correct a wrong memory by editing a line, which no summariser lets you do. You can read the whole memory without an agent, in any editor. And the README states that Obsidian needs no setup at all: point it at the project folder and the same wikilinks and frontmatter show up in its graph view, so the writer-facing tool you probably already use becomes the inspection interface. ### Semantic search is off by default Semantic search is available and off by default. The README documents hybrid full-text plus vector ranking with FastEmbed embeddings on SQLite or Postgres, and an optional local cross-encoder reranker, defaulting to `jinaai/jina-reranker-v1-tiny-en`, behind two environment variables: Two environment variables switch it on: `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true` and `BASIC_MEMORY_RERANKER_ENABLED=true`. Leave both off to start. Title and text search over a few hundred notes is fast and predictable, and predictable is what you want when you are checking whether a name has been used. ### graphify-novel: the only one built for prose `Anshler/graphify-novel`. 66 stars, MIT, last commit 16 April 2026, no published release, and the whole product is a 25,512-byte `SKILL.md` plus a README. All of that is from the GitHub API and the repository contents endpoint on 3 September 2026. I am telling you the size and the date because you should calibrate on them: this is one person's skill file, not a maintained product. ![graphify-novel has 66 GitHub stars](https://adityaarsharma.com/wp-content/uploads/2026/09/782adbc0-e224-4353-a67c-8cea13bbf698_2400x2400.png)graphify-novel, the only tool here designed around a manuscript. GitHub API, 3 September 2026.It is also, as far as I can find, the only thing in this category designed around a manuscript. ### What it actually does It scaffolds a story bible from a premise, reviews a draft against that bible for contradictions and unresolved setups, and updates character and thread files after you write. The structure it imposes: `/ chapters/ finished manuscript files draft/ work in progress, excluded from the graph static/ excluded from the graph bible/ premise.md timeline.md characters/ threads/ world/ graphify-out/ generated knowledge graph .graphifyignore # from the graphify-novel README, read 3 September 2026`The commands that matter for the six needs listed earlier are `status`, which reports open threads, character states and unresolved setups, and `query` and `path`, which trace how two story elements connect across the full manuscript. Those are relationship questions, and they are the ones a flat notes file answers badly. ### Two honest caveats Two honest caveats. First, it runs on top of the Graphify skill, which is a separate install, and the README links Graphify at `github.com/safishamsi/graphify`. That URL redirects: following it through the GitHub API on 3 September 2026 lands on `Graphify-Labs/graphify`. The link works, it is just not the current name. I compared Graphify against its nearest rival in [the codebase-memory-mcp and Graphify comparison](https://adityaarsharma.com/codebase-memory-mcp-vs-graphify/). Second, and this is stated by the author rather than discovered by me: if you already have a bible or story-tracking structure, it will not be compatible. You move it outside the project and retrofit it after initialising. For a manuscript already in progress that is a real afternoon of work. ## What does not work, and why ### Anything that needs a graph database **Anything that requires a graph database.** Graphiti's README lists Neo4j 5.26, FalkorDB 1.1.2 or Amazon Neptune as requirements, and marks the embedded Kuzu driver deprecated because the upstream project is no longer maintained. Its temporal model is genuinely well suited to fiction, since 'this was true until chapter nine' is exactly a validity window. It is still a database you have to keep running to write a novel, and that is the setup that quietly stops being running in March. ### Khoj, the near-miss **Khoj** is the interesting near-miss, because it is aimed at documents rather than code: 37,013 stars, AGPL-3.0, self-hostable by design. But its own `docker-compose.yml`, which I pulled from the master branch on 3 September 2026, brings up `pgvector/pgvector:pg15`, a SearXNG container and a sandbox container before you index anything. Also worth knowing before you commit: its last published GitHub release is 2.0.0-beta.28, dated 26 March 2026, while the repository was pushed to on 2 August 2026. Active development, no shipped release in five months. ### Code-graph tools pointed at prose **Code-graph tools pointed at prose.** They will run. Graphify will map your Markdown, and its own capability table says docs, PDFs, images and video all map into the same graph. What it also says is that the code pass is local and deterministic while the semantic pass over documents uses your assistant's model or a configured API key. So for a writer, the free deterministic part is the part you do not need, and the part you do need is the metered one. Know that before you assume the zero-credit claim applies to you. **Relying on chat history.** Basic Memory's README puts the reason well: chat history captures conversations but is not structured knowledge. A transcript contains the decision and also contains the three wrong ideas you had before it, with nothing marking which is which. ## The setup I would actually run Basic Memory for the six needs list, because the unit is a file you can fix, and it needs no server. Add graphify-novel on top only if you are working on something long enough that thread tracking is a real problem, and only after reading its `SKILL.md`, because at 25 KB you can read the entire product in fifteen minutes and know exactly what it does. If you want your raw session transcripts searchable as well, MemPalace alongside it, specifically because it does not paraphrase. Two systems, one for curated notes and one for the raw record, is a better shape than one system trying to be both. If you are weighing whether any of this needs to be self-hosted at all, the running costs are in [the self-hosted memory cost breakdown](https://adityaarsharma.com/self-hosted-ai-memory-cost/), the storage backends and licences per project are in [the platform comparison table](https://adityaarsharma.com/ai-memory-platform-comparison/), and the whole cluster sits under [the AI memory tools comparison](https://adityaarsharma.com/ai-memory-tools-compared/). One thing to do in the next ten minutes: open the memory tool you are using, save one sentence containing a phrase you would recognise, and search it back. If you get your sentence, you have a system a writer can use. If you get a tidy summary of your sentence, you have a system that will lose your voice one paragraph at a time. ## Resources - [Basic Memory on GitHub](https://github.com/basicmachines-co/basic-memory) - Markdown entity format and local install- [Basic Memory knowledge format docs](https://docs.basicmemory.com/concepts/knowledge-format) - observations, categories and relations in full- [graphify-novel on GitHub](https://github.com/Anshler/graphify-novel) - the story bible skill, README and SKILL.md- [MemPalace on GitHub](https://github.com/MemPalace/mempalace) - the verbatim storage claim and backend table- [MemPalace: the palace concept](https://mempalaceofficial.com/concepts/the-palace.html) - how wings, rooms and drawers scope a search- [Khoj on GitHub](https://github.com/khoj-ai/khoj) - and its docker-compose.yml if you want to see the stack first- [Obsidian](https://obsidian.md/) - reads the same Markdown Basic Memory writes, with no configuration --- # Cognee Alternatives: What You Are Actually Replacing Source: https://adityaarsharma.com/cognee-alternatives/ Step two of the Cognee quickstart, copied from its README on 2 September 2026: ![Cognee will not index without an LLM key. Only one of the alternatives removes that constraint rather than relocating it.](https://adityaarsharma.com/wp-content/uploads/2026/09/85379219-3269-45bb-9f5d-06c06b054c67_2912x1632-scaled.png)`import os os.environ["LLM_API_KEY"] = "YOUR OPENAI_API_KEY"`That line is why most people end up searching for a Cognee alternative. It is not a bug and Cognee is not hiding it. It is a design commitment: the pipeline builds a knowledge graph by having a model read your data, so a model has to read your data. Everything that follows, the per-item cost, the vendor dependency, the fact that ingestion speed is governed by somebody else's rate limits, comes out of that one line. There are three separate reasons to leave, and they point at three different replacements. Picking the wrong one gets you a project with the same constraint under a different name. Graphiti, the most commonly recommended swap, also defaults to OpenAI for both inference and embeddings. If you left Cognee over LLM cost, you have moved sideways. ## What you are actually replacing Cognee is a pipeline that turns documents into a graph plus a vector index, exposed through four operations its README names as `remember`, `recall`, `forget` and `improve`. Local development runs fully embedded on SQLite, LanceDB and Kuzu, with no external services to stand up. Licence is Apache 2.0. As of 2 September 2026 the repository shows 30.4k stars. Three things about it drive people to the search box. ### 1. The model is in the read path too, not only the write path From the performance tuning section of the README: `AUTO_FEEDBACK=false` "removes the one LLM call cognee makes after each answered query to self-tune its memory". That is a per-query charge on top of the per-document ingestion charge, and it is on by default. If you budgeted for ingestion only, your bill will not match your model. The same section contains a warning worth repeating, because it invalidates a lot of comparison blog posts: `CACHING=false` disables session memory entirely, and the README says "If you're benchmarking cognee, leave it on - turning it off benchmarks cognee with its memory layer removed." Anyone who published a head-to-head with caching disabled measured something else. ### 2. Parts of what looks open are on the other side of a commercial line The README's headline feature for version 1.0 is running the whole memory layer on a single Postgres instance instead of a graph database plus a vector database plus Redis plus a relational store. Directly under it: > Using Postgres as a graph store is currently a released as a demo feature. The production ready feature is available as a licenced product. > > Cognee README, read 2 September 2026![Cognee README warning that the Postgres graph store is a demo feature and the production ready feature is licenced](https://adityaarsharma.com/wp-content/uploads/2026/09/586fdef1-d928-484b-91b8-6338f9b57484_2800x1720-scaled.png)The Cognee README on GitHub, read 3 September 2026. The warning sits directly under the version 1.0 headline feature.The pricing page draws the same line elsewhere. On cognee.ai/pricing, read 2 September 2026, bi-temporal memory with conflict resolution, provenance on every answer, and per-user personalisation are listed under Enterprise, delivered as a bring-your-own-cloud engagement. The Standard tier is $2.50 per 1M tokens processed plus $5 per additional workspace, and the free tier is 1M tokens with one workspace. ![Cognee pricing page showing Free, Standard at 2.50 dollars per 1M tokens, and Enterprise as a bring your own cloud engagement](https://adityaarsharma.com/wp-content/uploads/2026/09/a4f884a7-eb0c-4667-8c36-33a8705e78eb_2800x1720-scaled.png)cognee.ai/pricing, read 3 September 2026. Standard is $2.50 per 1M tokens processed; Enterprise is a bring-your-own-cloud engagement.This is a legitimate open-core business and I have no complaint about it. It matters only because bi-temporal memory is the exact capability Graphiti ships in its Apache 2.0 core. If that is what you came for, the alternative is not a downgrade. ### 3. Backend choice is a hard constraint, not a preference With `ENABLE_BACKEND_ACCESS_CONTROL=true`, the default, each user and dataset combination gets isolated graph and vector databases. Both layers have to support isolation. Neptune, remote Ladybug and the community vector adapters do not. Cognee raises an error naming the unsupported backend rather than silently falling back to shared databases, which is the right behaviour and also means you cannot discover this problem gradually. You discover it when you switch. ## The alternatives, on checkable properties | Project | Licence | Stars, 2 Sep 2026 | LLM needed to index? | Storage shape | | ------- | ------- | ----------------- | -------------------- | ------------- | | Cognee | Apache 2.0 | 30.4k | Yes, plus one call per answered query by default | Graph plus vector, embedded locally or Postgres | | Graphiti | Apache 2.0 | 30.5k | Yes, defaults to OpenAI for both LLM and embeddings | Bi-temporal graph, bring your own graph DB | | mem0 | Apache 2.0 | 64.6k | Yes, one call per add | Vector store plus extracted facts | | LightRAG | MIT | 39.3k | Yes, an EXTRACT role model | Graph plus vector, many backends | | MemPalace | MIT | 58.8k | No, local embeddings on the default path | Verbatim drawers plus SQLite temporal graph | Of those five, exactly one will index your data without an API key. That is the single most useful sorting fact in this category, and it is checkable in five minutes from the READMEs. ## Graphiti, if you left for the temporal graph Graphiti is the open-source temporal graph engine underneath Zep. Facts are triples with validity windows, so nothing is deleted when it changes, it is invalidated and the history stays queryable. Entities and relationships trace back to *episodes*, the raw ingested data, which gives you provenance in the core rather than on an enterprise plan. Entity and edge types are defined with Pydantic models, or left to emerge from the data. ![Graphiti README installation requirements listing Python 3.10, Neo4j 5.26, FalkorDB 1.1.2 or Amazon Neptune, and an OpenAI API key](https://adityaarsharma.com/wp-content/uploads/2026/09/47d0825b-99d1-400c-b410-c2879a0cd88b_2800x1720-scaled.png)The Graphiti README installation section, read 3 September 2026. The graph backend and the OpenAI key are requirements, not options. ### What it costs to run The operational cost of that is stated plainly in the installation requirements: Python 3.10 or higher, plus Neo4j 5.26, or FalkorDB 1.1.2, or an Amazon Neptune cluster together with an OpenSearch Serverless collection for full-text search. Kuzu is still listed and is deprecated, because the upstream project is no longer maintained. If you built on the embedded Kuzu path to avoid running a database, that path is closing. ### Two details that decide it for you Two more details from the README that decide whether this works for you. Graphiti "works best with LLM services that support Structured Output", and using others "may result in incorrect output schemas and ingestion failures", which the docs note is worse with smaller models. And `SEMAPHORE_LIMIT` ships at 10 concurrent operations specifically to avoid 429 rate limit errors from your provider. Ingestion throughput and API spend are the same dial. `pip install graphiti-core # or with a graph backend that runs in one container pip install graphiti-core[falkordb] docker run -p 6379:6379 -p 3000:3000 -it --rm falkordb/falkordb:latest` ## MemPalace, if you left for the LLM bill This is the only alternative here that removes the constraint rather than relocating it. Content is stored verbatim, embeddings are computed locally by a model on your disk, roughly 30 MB for MiniLM or 300 MB for embeddinggemma-300m, and the README states that no API key is required for the core path. Licence is MIT. It ships 45 MCP tools and auto-save hooks for Claude Code, Codex CLI and Cursor. ### What you give up with MemPalace Be clear about what you give up. You get retrieval, a structured index and a temporal knowledge graph. You do not get Cognee's ontology generation or its per-document-type entity extraction. If the custom pipeline is your product, this is the wrong swap. If the pipeline was plumbing you tolerated to get search over your own history, it is the right one. I go through that trade in detail in [mem0 vs MemPalace, where the benchmark numbers are not measuring the same thing](https://adityaarsharma.com/mem0-vs-mempalace/). ## LightRAG, if you wanted the pipeline without the platform LightRAG from HKUDS is the closest thing to Cognee's shape that does not present itself as a memory platform. Its README documents role-specific LLM configuration with four distinct roles, EXTRACT, QUERY, KEYWORDS and VLM, each independently configurable, which is genuinely useful if you want a cheap model doing extraction and a strong one answering. It offers four chunking strategies, a reranker as the default query mode, document deletion with automatic knowledge graph regeneration, and storage on Postgres, Neo4j, MongoDB or OpenSearch. RAG-Anything was merged into it in May 2026 for multimodal parsing through MinerU or Docling. Licence is MIT, 39.3k stars, and the latest release tagged on 2 September 2026 is v1.5.7, which tells you the project is being worked on today rather than last spring. It still needs a model for extraction. What it gives you is control over which model, per role. ## Letta: read the repository before you shortlist it Letta appears on most 2026 alternatives lists, including [my own comparison of AI memory tools for a second brain](https://adityaarsharma.com/ai-memory-tools-compared/). Check the repository yourself before you act on any of them. As of 2 September 2026, `letta-ai/letta` states that it "now serves as a landing page for the Letta project". The V1 server source lives on an `archive` branch and the README says it "is unsupported, receives no fixes or security updates, and should not be used in production". Active development moved to `letta-ai/letta-code`. The last release tagged on the old repository is v0.16.8, 14 May 2026. ### Why alternatives lists go stale This is the general failure mode of alternatives lists, mine included. They are snapshots. In the four and a half months since I published that comparison, mem0 went from 53k stars to 64.6k, MemPalace from 46.7k to 58.8k, and Cognee from 15.5k to 30.4k. Anything you read about this category that has not been rechecked this month is describing a different field. ## The comparison nobody has published There is no run of these five systems on one dataset, with one metric, one reader model and one hardware configuration, by anyone without a stake in the outcome. I have not done it either, and I am not going to imply otherwise with a table of numbers scraped from five different experiments. ### Every project says its own number is not comparable Each project is honest about this in its own documentation, which is worth noticing. Cognee's BEAM results come with the caveat that they are "a directional signal rather than a definitive measure". MemPalace refuses to publish a side-by-side against its competitors because retrieval recall and end-to-end QA accuracy are different quantities. mem0 marks its headline scores as platform numbers with proprietary optimisations not present in the open-source SDK. Three projects saying "our number is not comparable to theirs" is a stronger signal than any of the numbers. ### How to read a benchmark claim Do the same thing you would do with any vendor claim: separate the headline from the mechanism, and verify only the mechanism, because that is the half you can check from outside. It is the method that caught a real problem when [the commercial malware scanners reported a site clean while it was serving spam to Googlebot](https://adityaarsharma.com/the-scanner-said-clean-the-site-was-serving-spam-to-googlebot/). The scores were confident. The mechanism could not have detected the thing that was happening. ## How to choose in ten minutes Answer one question and the shortlist collapses. - **Leaving over cost or over sending data to a provider?** MemPalace. It is the only one of the five that indexes without a model. - **Leaving because bi-temporal reasoning is on the Enterprise plan?** Graphiti. Same capability, Apache 2.0, at the price of running Neo4j or FalkorDB. - **Leaving because you want a pipeline you control per stage?** LightRAG. Role-specific models are the feature that matters. - **Leaving because you want the largest ecosystem and drop-in integrations?** mem0. - **Leaving because the ops surface is too big?** Postgres with pgvector and your own hundred lines. Cognee's own 1.0 release note argues that collapsing four services into one Postgres is viable, and if it is viable for them it is viable for you. ### Cost the new stack before you move Before you migrate anything, work out what the new stack costs at your real volume. The arithmetic is in [what self-hosted AI memory actually costs to run](https://adityaarsharma.com/self-hosted-ai-memory-cost/), and for most solo builders the answer is small enough that licence and data ownership should decide instead. And if you are wiring any of these into a coding agent, the practical limits I hit doing that against production systems are in [running Claude Code against WordPress](https://adityaarsharma.com/running-claude-code-against-wordpress-the-complete-setup/). ## More on ai memory and mcp - [mem0 vs MemPalace: The Benchmark Numbers Are Not Measuring the Same Thing](https://adityaarsharma.com/mem0-vs-mempalace/)- [What Self-Hosted AI Memory Actually Costs to Run](https://adityaarsharma.com/self-hosted-ai-memory-cost/)- [MCP Server vs RAG: Which One You Actually Need](https://adityaarsharma.com/mcp-server-vs-rag/) ## Resources - Cognee repository: [github.com/topoteretes/cognee](https://github.com/topoteretes/cognee) - Cognee documentation: [docs.cognee.ai](https://docs.cognee.ai/) - Cognee pricing: [cognee.ai/pricing](https://www.cognee.ai/pricing) - Cognee paper, Markovic et al. 2025: [arXiv 2505.24478](https://arxiv.org/abs/2505.24478) - Graphiti repository: [github.com/getzep/graphiti](https://github.com/getzep/graphiti) - Zep temporal knowledge graph paper: [arXiv 2501.13956](https://arxiv.org/abs/2501.13956) - MemPalace repository: [github.com/MemPalace/mempalace](https://github.com/MemPalace/mempalace) - LightRAG repository: [github.com/HKUDS/LightRAG](https://github.com/HKUDS/LightRAG) - Letta, current source: [github.com/letta-ai/letta-code](https://github.com/letta-ai/letta-code) --- # The Plugin That Is Slowing Your Site, and How to Find It Without Guessing Source: https://adityaarsharma.com/find-the-plugin-slowing-your-wordpress-site/ This is the readout that ends the argument, sitting in the WordPress admin bar: `1.82s 28,840KB 0.0052s 19Q` Page generation time, peak memory, total database query time, query count. Look at the third and fourth figures next to the first. That page took 1.82 seconds to build and spent 5.2 milliseconds of it in the database, across 19 queries. The database is 0.3 percent of the problem. Which means the standard advice, deactivate your plugins one at a time until the site gets faster, would have told you almost nothing here, after an hour of taking a production site down and up. There is a tool that attributes the cost to a named plugin in one page load, and the reason it can do that is a piece of engineering worth understanding rather than just installing. ![The Query Monitor listing on the WordPress.org plugin directory, showing version 4.0.7, 200,000 plus active installations, and a header image of the Queries by Component panel attributing queries to wordpress-seo, core, woocommerce and edit-flow.](https://adityaarsharma.com/wp-content/uploads/2026/09/2d4c4834-8612-4436-82b8-73a3bae31a01_2800x3000-scaled.png)wordpress.org/plugins/query-monitor/, screenshot taken 2 September 2026. Version 4.0.7, 200,000+ active installations, tested to 7.0.4. The header image is the plugin's own screenshot of the Queries by Component panel. ## Why Query Monitor can name the plugin and your host's APM cannot Every profiler can tell you a query was slow. Almost none can tell you which plugin issued it, because by the time a query reaches the database the call stack is gone. ### The db.php drop-in loads before WordPress Query Monitor solves this before WordPress boots. From its own README, on the develop branch: > In order to do a few clever things, Query Monitor symlinks a custom db.php into your WP_CONTENT_DIR which means it loads very early. This file gets included before the database driver is loaded, meaning this portion of Query Monitor loads before WordPress even engages its brain. > > github.com/johnbillion/query-monitor README, read 2 September 2026 That drop-in replaces the `wpdb` class with a subclass that records, for every single query, the full stack trace at the moment it ran. WordPress supports exactly one `db.php` drop-in, and that slot is what buys you three things nothing else gets: - Queries that run **before plugins load**, which is where a surprising amount of option loading happens.- The **calling function and the owning component** for each query, derived from the stack trace, which is how the Queries by Component panel exists at all.- The **result** of each query, so affected rows and error messages are shown rather than guessed at. ### What you lose when the symlink cannot be created The consequence matters more than the mechanism. The README is explicit: if `WP_CONTENT_DIR` is not writable and the symlink cannot be created, Query Monitor still runs, but this extended functionality is gone. You get query timings with no idea who is responsible, which looks identical to a working install until you go looking for the answer you installed it for. So the first thing to check is not a setting. It is a file. Run `ls -la wp-content/db.php` and read what comes back. A symlink pointing into the plugin is correct. A real file that is not Query Monitor's means another plugin already claimed the slot, which happens with some page-cache and monitoring plugins, and only one of them can win. ![The johnbillion/query-monitor repository on GitHub, the source of the db.php drop-in explanation.](https://adityaarsharma.com/wp-content/uploads/2026/09/9ef51068-ac29-4dc9-8604-972f39bc9c1e_2800x1900-scaled.png)github.com/johnbillion/query-monitor, screenshot taken 3 September 2026. The db.php symlink explanation quoted above is in this README. ## Setting it up so the numbers are real Query Monitor 4.0.7, updated 20 June 2026, needs WordPress 6.2 or higher and PHP 7.4 or higher, per the plugin directory listing above. Install it, then add these to `wp-config.php` above the *stop editing* line: - `WP_DEBUG` true and `WP_DEBUG_LOG` true.- `WP_DEBUG_DISPLAY` false, plus `@ini_set( 'display_errors', 0 )`, so errors go to the log rather than into the page.- `QM_HIDE_SELF` true, which excludes Query Monitor's own overhead from the numbers. ### SAVEQUERIES is set for you You do not need to define `SAVEQUERIES`. Query Monitor does it for you, and you can read the line in its source: `// collectors/db_queries.php if ( ! defined( 'SAVEQUERIES' ) ) { define( 'SAVEQUERIES', true ); } if ( ! defined( 'QM_DB_EXPENSIVE' ) ) { define( 'QM_DB_EXPENSIVE', 0.05 ); }` ### QM_DB_EXPENSIVE decides what turns red The second constant is the one nobody knows about and it changes how you read the whole panel. **Query Monitor's definition of a slow query is 0.05 seconds.** Fifty milliseconds. That is the threshold at which a row turns red and the warning count in the admin bar goes up. Fifty milliseconds is a reasonable default and it is wrong for most sites. On a tuned server a 20 ms query repeated forty times is a far worse problem than one 60 ms query, and only the second one turns red. Set it to something that matches your site before you trust the colours: `define( 'QM_DB_EXPENSIVE', 0.01 );` flags anything over 10 ms. ## The method, in the order that finds things fastest ### Work the panels in this order Load the slowest real page as a logged-out user in one browser and as an administrator in another, because they are different requests with different costs. Then work the panels in this order. Each step either finds the culprit or eliminates a whole category. - **Read the admin bar first.** Four numbers. If total query time is a small fraction of page generation time, stop looking at the database entirely. This single comparison saves most people an afternoon.- **Queries by Component.** Sort by time, not by count. This is the panel in the screenshot above, and it names plugins directly. A component with 4 queries and 400 ms outranks one with 200 queries and 30 ms.- **Queries by Caller.** When a component looks bad, this tells you which function inside it. That is the difference between "disable this plugin" and "disable this one feature of this plugin".- **HTTP API Calls.** The panel that finds the real killers. Every server-side request the site makes, with the time taken and the responsible component. A licence check or an analytics ping that runs synchronously on page load costs whole seconds and produces zero database queries, so it is invisible everywhere else.- **Timeline.** Where the time went across the request, rather than which subsystem it was in. Use it when the first four panels all look fine and the page is still slow.- **Scripts and Styles.** Every handle, its URL, its dependencies, and any broken or missing dependency. This is where you find out that four plugins each enqueue their own copy of the same library.- **Hooks and Actions.** Last, because it is the noisiest. Useful when a plugin is doing work on a hook that fires far more often than its author assumed. The order is deliberate. Steps 1 and 4 catch the two things that produce the most wasted debugging: a slow page that has nothing to do with the database, and a slow page caused by a network call to somebody else's server. ![Bar chart contrasting 1.82 seconds of page generation time with 0.0052 seconds of database query time.](https://adityaarsharma.com/wp-content/uploads/2026/09/33c7e48b-0f51-44f5-ab85-1d2da9d0fc98_2912x1632-scaled.png)Built from the admin bar readout at the top of this post: 1.82s, 28,840KB, 0.0052s, 19Q. ## What the numbers actually mean | Number | What it is | How to read it | | ------ | ---------- | -------------- | | Page generation time | Wall-clock time PHP spent building the response | The budget everything else is spent from. Compare every other figure to this one. | | Peak memory usage | Highest memory PHP held during the request | Close to your `memory_limit` means you are one plugin away from a white screen. | | Database query time | Total time inside the database driver | If this is under 10 percent of generation time, the database is not your problem. | | Query count | Number of queries run | The most over-quoted number in WordPress. A high count with low time is fine. | | Red query rows | Queries slower than `QM_DB_EXPENSIVE` | Default 0.05 s. Change the constant or the colours mislead you. | | Duplicate query warnings | The identical query run more than once | The strongest signal of a missing object cache, because a persistent one removes exactly this. | ### Duplicate queries, and which cache owns them That last row is where this connects to caching. Duplicate queries in a single request are what a non-persistent object cache already prevents. Duplicate queries *across* requests are invisible to Query Monitor and are what a persistent object cache fixes. If your Queries by Component panel is clean and the site is still slow under traffic, that is the gap. ## Five things Query Monitor cannot see This is the part that makes it useful rather than magic. Knowing the blind spots stops you concluding a site is healthy when the measurement simply did not reach. - **Anything outside PHP.** Network time to your server, TLS negotiation, CDN behaviour, the opcode cache. On my own site the largest single cost is a round trip to origin that happens before PHP starts, and Query Monitor is structurally incapable of showing it.- **The front end as a real visitor sees it.** Query Monitor renders for logged-in users with permission. An administrator request loads the admin bar, more capability checks and often more plugin code than an anonymous visitor ever triggers, so the numbers are an upper bound.- **Anything served from the page cache.** If your page cache is working, the fast path never reaches PHP, so there is nothing to profile. You are always profiling a cache miss.- **Browser-side cost.** Script execution, layout, interaction latency. Query Monitor lists the scripts a page enqueues, and says nothing about what they do once they run.- **Cron.** Work deferred to `wp-cron` disappears from the request that scheduled it and reappears in some unlucky visitor's page load later. ### One honest note about this post I did not screenshot Query Monitor running on my own site. `wp-admin` on adityaarsharma.com is behind a custom login path I did not use for this write-up, and I am not going to fabricate a panel or pass off a stock image as my own. The screenshot above is the real plugin directory page, and the panel in its header is the plugin author's own screenshot, which is what it is. ![The querymonitor.com documentation site, the plugin](https://adityaarsharma.com/wp-content/uploads/2026/09/0cbd1cbd-6fa5-4a6c-8eb8-5c3d2f80139d_2800x2000-scaled.png)querymonitor.com, screenshot taken 3 September 2026. ## The failure mode of the deactivate-everything method It is worth saying why the common advice fails rather than only saying it does. ### Plugins are slow in combination, not alone Deactivating plugins one at a time tests plugins in isolation, and slow WordPress sites are usually slow because of interaction. Plugin A adds a meta query. Plugin B adds a filter that runs on every post in the loop. Neither is slow alone. Together they are quadratic. Turn either one off and the site is fast, which tells you both are innocent and both are guilty, and you will conclude the last one you toggled is the culprit. It also cannot see cost that is not attributable to activation at all: a plugin that is fine until the options table has 400 autoloaded rows, or one whose licence check is fast in your country and slow from your host's datacentre. Query Monitor's Queries by Caller and HTTP API Calls panels find both of those in one page load, on the site as it actually is, without taking anything down. ## Do this in the next ten minutes Install Query Monitor on your staging copy, add `define( 'QM_DB_EXPENSIVE', 0.01 );` to `wp-config.php`, and confirm the drop-in exists: `ls -la wp-content/db.php` Then load your slowest template and read exactly two things: the four numbers in the admin bar, and the HTTP API Calls panel. If database query time is a small share of page generation time and there is a multi-hundred-millisecond outbound request in that second panel, you have found your plugin, and you never deactivated anything. ## Related reading - What the profiler cannot see, measured from outside with curl on this same site: [what actually makes a WordPress site slow](https://adityaarsharma.com/what-actually-makes-a-wordpress-site-slow/).- Duplicate queries across requests are an object cache problem, and this is which layer owns which symptom: [WordPress caching layers, and which one your problem is actually in](https://adityaarsharma.com/wordpress-caching-layers-which-one-is-failing/).- How the finished page is graded once PHP has done its part: [Core Web Vitals for WordPress in 2026](https://adityaarsharma.com/core-web-vitals-wordpress-2026/).- The Scripts and Styles panel exists because plugins enqueue carelessly, and this is what that looks like from the theme side: [how to fix media query not working in WordPress](https://adityaarsharma.com/how-to-fix-media-query-not-working-in-wordpress/).- Knowing which plugins you are actually paying for and running, before you profile them: [managing WordPress plugin subscriptions for clients](https://adityaarsharma.com/managing-wordpress-plugin-subscriptions-for-clients/).- A small piece of the same plugin-internals surface, written from the other direction: [add a Go to Settings link after installing any WordPress plugin](https://adityaarsharma.com/go-to-plugin-settings-link-after-install/). ## Resources - [Query Monitor 4.0.7](https://wordpress.org/plugins/query-monitor/) on the WordPress.org plugin directory, read 2 September 2026.- [johnbillion/query-monitor](https://github.com/johnbillion/query-monitor) on GitHub. The db.php explanation and both constants above are quoted from this repository.- [querymonitor.com](https://querymonitor.com/), the plugin's own documentation site.- [Creating the db.php symlink manually](https://querymonitor.com/help/db-php-symlink/), for hosts with a read-only wp-content.- [WP_DEBUG and related constants](https://developer.wordpress.org/apis/wp-config-php/#debug), WordPress developer reference.- [wpdb](https://developer.wordpress.org/reference/classes/wpdb/), the class Query Monitor subclasses to do all of this. --- # Bulk Editing WordPress Content Over the REST API Without Breaking Anything Source: https://adityaarsharma.com/bulk-edit-wordpress-rest-api/ This is the request that quietly destroys an archive: ![content.rendered is readonly for a reason.](https://adityaarsharma.com/wp-content/uploads/2026/09/7fa17e3f-931b-4e04-9518-7d8f5aa2f324_2912x1632-scaled.png)`curl -s "https://example.com/wp-json/wp/v2/posts?per_page=100" | jq '.[0].content.rendered'`It looks like it reads the post. It does not. Without `context=edit` the REST API hands you `content.rendered`, which is the post run through the `the_content` filter: block markup already turned into HTML, shortcodes already expanded, embeds already resolved into iframes, and every filter every plugin has hooked already applied. Write that string back with `POST /wp/v2/posts/<id>` and you have replaced the source of the post with a snapshot of its output. Do it in a loop over 400 posts and you have done it 400 times. The schema says so plainly. In `class-wp-rest-posts-controller.php`, the `content` property declares `raw` with `'context' => array( 'edit' )` and `rendered` with `'readonly' => true`. You are only ever allowed to write what you read from `raw`, and `raw` only exists in the edit context. ![WordPress REST API posts reference showing the context argument defaulting to view](https://adityaarsharma.com/wp-content/uploads/2026/09/e4416216-696f-4332-83b6-a129b7b2fe90_2800x1720-scaled.png)The posts endpoint reference on developer.wordpress.org, read 3 September 2026. `context` defaults to `view`, and `edit` is opt-in.So rule one for any bulk edit: `context=edit` on every read, and never send back anything you took from `rendered`. ## Auth, in one line Application Passwords have shipped in core since WordPress 5.6. Users, then your profile, then the Application Passwords section at the bottom. You get a 24-character string with spaces in it. The spaces are part of it. Set three environment variables and the rest is one header. `WP_SITE` is the site root, `WP_USER` your login, and `WP_APP_PASS` the generated password with its spaces kept. Then call `/wp-json/wp/v2/users/me?context=edit` with `curl -u "$WP_USER:$WP_APP_PASS"`. ### When the Authorization header never arrives That is the whole handshake: HTTP Basic over TLS, which the REST API handbook documents as the supported remote method. If it returns your user object, you are authenticated. If it returns the public view of a user with no `capabilities` key, your header is being stripped, which happens on some Apache and CGI setups because `Authorization` is dropped before PHP sees it. Two checks worth running before you write a script that assumes it works. Everything below assumes posts that already exist. If the script also has to create them, [publishing to WordPress from a script](https://adityaarsharma.com/publish-to-wordpress-from-a-script/) covers the create path, the media endpoint and the status rules that come with it. ## Read the whole site without lying to yourself `per_page` is capped at 100. The handbook is explicit: 1 to 100, and over 100 the request is rejected rather than truncated. Every paginated response carries `X-WP-Total` and `X-WP-TotalPages`, so page until you have seen every page rather than until you get an empty array. ![WordPress REST API pagination handbook showing X-WP-Total and X-WP-TotalPages headers](https://adityaarsharma.com/wp-content/uploads/2026/09/b47bceca-4e76-47ad-9acd-797fb4dd0fd9_2800x1720-scaled.png)The pagination handbook, read 3 September 2026. Every paginated response carries `X-WP-Total` and `X-WP-TotalPages`, and collections default to `orderby=date` descending. ### Two defaults that will quietly lose you posts Two things people get wrong here. `status` defaults to `publish`, so a naive crawl silently misses drafts, pending, private and scheduled posts. And `orderby` defaults to `date` descending, which means that if your edit changes anything the ordering depends on, the result set shifts under you between page 3 and page 4 and you skip posts without noticing. Order by `id` ascending. It is the one field a content edit never changes. The reader itself is short. What matters is the parameters it sends on every page. - Authenticate with an `Authorization: Basic` header built from the user and the application password, and send an honest `User-Agent`.- `context=edit`, so the response carries `content.raw` rather than `content.rendered`.- `status=publish,draft,pending,private,future`, because the default is `publish` alone.- `orderby=id` with `order=asc`, so the sequence cannot shift under you between pages.- `per_page=100`, walking `page` upward until it reaches the `X-WP-TotalPages` response header.- `_fields=id,slug,link,status,title.raw,content.raw`, with about 0.4 seconds of sleep between pages.- Raise on any HTTP error rather than swallowing it, and print the response body when you do.`_fields` is doing real work there. It is handled by `rest_filter_response_fields()`, it understands dotted paths like `content.raw`, and on a large archive it is the difference between a 40 MB read and a 4 MB one. Ask for what you need. ![WordPress REST API global parameters page describing the _fields query parameter](https://adityaarsharma.com/wp-content/uploads/2026/09/23ec50f5-9c39-4892-90da-e65854dd1836_2800x1720-scaled.png)The global parameters page, read 3 September 2026. `_fields` is a documented parameter on every resource, not a trick. ## The dry run is the whole technique A bulk edit has two phases and they belong in two separate program runs with a file in between. Phase one reads and produces a plan on disk. You read the plan with your eyes. Phase two applies exactly what is in the plan and touches nothing else. If the same script does both, you will one day run it with the wrong argument. Phase one walks every post, skips the ones where the find string is absent, and writes a row for each of the rest. The row carries the ID, the link, the status, the title, the number of hits in that post, the character delta between old and new, and the new content in full. It writes that list to `plan.json`, prints one line per post and a total, and says that nothing has been changed. ### The three numbers to read in the plan Three numbers make that output useful rather than decorative. The post count tells you whether your search string is broader than you thought. The replacement count tells you whether some single post has 60 of them. The character delta tells you whether you are about to delete half a post: a large negative number on one row is the signal that your match ate something it should not have. ### Sanity checks before you apply Sanity check the plan before applying it. If a find and replace across an archive returns every post you have, your pattern is wrong. If it returns one, ask why. I usually build the list of URLs I expect to be affected separately, using [the sitemap into Google Sheets method](https://adityaarsharma.com/how-to-extract-links-from-websites-using-sitemap-in-sheets/), and compare counts before I trust anything. ## Applying the plan An update is `POST /wp/v2/posts/<id>`. WordPress accepts POST for updates as well as PUT and PATCH; the handbook's own example uses POST. Send only the fields you are changing. Every field you include is a field you can break. Phase two loads `plan.json`, optionally truncates it to a `limit`, and for each row sends `POST /wp/v2/posts/<id>` with a body of `{"content": ...}` and nothing else. It pauses about 0.4 seconds between writes, collects failures rather than stopping at the first one, and prints the updated and failed counts at the end. Run it with `limit=1` first. Open that post in the editor, look at it, then run the rest. That single manual look catches more than any assertion you can write, because the thing you are checking is whether the block markup still parses, and the editor is what parses it. ## The batch endpoint, and what it does not do WordPress 5.6 added a real batch route. It is registered on the server root, so it lives at `/wp-json/batch/v1`, and it takes a `requests` array. Each entry in that array is a `method`, a `path` such as `/wp/v2/posts/1234`, and a `body`. Set `validation` to `require-all-validate`, send the rows in chunks of 25, and read the `responses` array that comes back. ### Four facts about /batch/v1 Four facts about this route that are worth having before you rely on it, all from `class-wp-rest-server.php`: - The array is capped at 25 requests, from `get_max_batch_size()`, filterable through `rest_get_max_batch_size`. Send 26 and the whole call is rejected on schema validation.- Only `POST`, `PUT`, `PATCH` and `DELETE` are allowed. There is no batched read, so this speeds up writes only.- A route has to opt in. The posts controller does, with `protected $allow_batch = array( 'v1' => true )`. Many custom endpoints and plenty of plugin routes do not, and you get `rest_batch_not_allowed` with a 400.- **It is not a transaction.** `require-all-validate` runs schema validation and sanitisation across every request first and, if any fail, returns `{"failed": "validation"}` without executing anything. Once execution starts there is no rollback. Request 12 can fatal after requests 1 to 11 have already been written. ### 207 does not mean success The response is always HTTP 207 with a `responses` array, each entry an envelope of `body`, `status` and `headers`. A client that only checks the outer status code will report success on a batch where every single request failed. Check each envelope's `status`. ## The safety net you already have `add_action( 'post_updated', 'wp_save_post_revision', 10, 1 )` is in `default-filters.php`, and REST updates go through `wp_update_post()` like everything else. So every post you touch gets a revision of its previous content, and the revisions are readable over the API: `curl -s -u "$WP_USER:$WP_APP_PASS" \ "$WP_SITE/wp-json/wp/v2/posts/1234/revisions?context=edit&per_page=5&_fields=id,date,content.raw" \ | jq -r '.[] | [.id, .date] | @tsv'` ### Two conditions on the revision safety net Two conditions on that. If `WP_POST_REVISIONS` is set to `false` in `wp-config.php`, and plenty of hosts and performance guides set it to `false` or a small integer, there is no safety net at all. And `wp_save_post_revision()` only saves when the content actually changed, which is fine, but it also means a botched second pass over the same posts can push the good version off the end of a `WP_POST_REVISIONS = 3` limit. Check the constant before you start, and take a database dump anyway. That is `wp config get WP_POST_REVISIONS` and then `wp db export pre-bulk-edit.sql`, in that order, before anything writes. ## The failures that are specific to this - **KSES rewrites your HTML if the account is not an administrator.** `kses_init()` calls `kses_init_filters()` for any user without the `unfiltered_html` capability, which adds `wp_filter_post_kses` to `content_save_pre`. On multisite no one has that capability by default, super admin included. The result is that identical requests produce different stored content depending on who the Application Password belongs to. Test with the exact account the script will use, never with your own admin login.- **Taxonomy fields replace, they do not append.** `categories` and `tags` take an array of term IDs and set the post's terms to that array. Sending `{"tags": [42]}` removes every other tag. To add one, read the current array, append, send the union.- **`meta` is invisible unless it was registered for it.** Only keys registered through `register_post_meta()` with `show_in_rest` appear in the `meta` object or can be written. A key you can see in `wp_postmeta` and cannot see in the API has not been registered, and no amount of retrying changes that. That is often the point where a bulk edit has to move to WP-CLI instead.- **Changing `slug` changes the URL and leaves nothing behind.** The REST API does not create a redirect. If a bulk edit touches slugs, plan the redirects in the same pass, and read up on [fixing 301 errors in WordPress](https://adityaarsharma.com/how-to-fix-301-errors-in-wordpress/) before rather than after.- **Sending `date` can move a post's schedule.** `wp_insert_post()` flips `publish` to `future` whenever the supplied date is 60 seconds or more ahead, so an innocent-looking date normalisation pass can unpublish live posts. If that happens, [the scheduling mechanism and how to find posts stuck in future status](https://adityaarsharma.com/wordpress-missed-schedule-wp-cron/) is the next thing to read. ## Verify with a second read, not with the write response The response to a successful update is the post as WordPress now holds it, which is genuinely useful, and it is still the same request telling you about itself. Do a separate pass afterwards that reads the posts again and asserts what you expect. Read the plan back, collect the IDs, and request them a hundred at a time with `include`, `context=edit`, the same full `status` list and `_fields=id,content.raw`. Then assert the find string is gone from every one, and print the IDs where it is not. `include` takes an array of IDs, and combined with `per_page=100` that is one request per hundred posts. If anything comes back still holding the old string, you know before your readers do. ## When to stop using the API The REST API is the right tool when the change is content-shaped and the site is remote. It stops being the right tool in two situations. When the field you need is not exposed, WP-CLI over SSH reaches everything the API cannot: `wp post update <id> --meta_input='{"key":"value"}'`, or `wp post list --format=ids` piped into `xargs`, with `--defer-term-counting` when you are touching thousands of posts. And when the work is not content at all: regenerating attachments or [compressing an image library in bulk](https://adityaarsharma.com/how-to-compress-wordpress-images-in-bulk/) is a filesystem job, and pushing image bytes through a JSON API to do it is the slow way round. I keep [WP-CLI wired up over SSH](https://adityaarsharma.com/running-claude-code-against-wordpress-the-complete-setup/) for exactly that reason, and reach for whichever of the two fits the field I need to change. ## Do this before your next bulk edit Run the plan phase against production and the apply phase against nothing. Read the counts. Then run apply with a limit of one, open that post in the block editor, and confirm it still parses as blocks rather than as a single HTML lump. If it does, the other 399 will too, and if it does not you have spent one post finding out instead of an archive. ## More on wordpress automation - [Scheduling WordPress Content Properly: Why Posts Miss Their Schedule and How to Fix It for Good](https://adityaarsharma.com/wordpress-missed-schedule-wp-cron/)- [n8n and WordPress: What Is Actually Worth Wiring Together](https://adityaarsharma.com/n8n-wordpress-what-to-automate/)- [Publishing to WordPress From a Script: The Complete, Correct Way](https://adityaarsharma.com/publish-to-wordpress-from-a-script/) ## Resources - [Posts endpoint reference](https://developer.wordpress.org/rest-api/reference/posts/), the full schema and which fields exist in which context- [Pagination in the REST API handbook](https://developer.wordpress.org/rest-api/using-the-rest-api/pagination/), where the 100 cap and the `X-WP-Total` headers are documented- [Authentication in the REST API handbook](https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/), Application Passwords over Basic auth- [Application Passwords integration guide on make.wordpress.org](https://make.wordpress.org/core/2020/11/05/application-passwords-integration-guide/)- [wp post update in the WP-CLI handbook](https://developer.wordpress.org/cli/commands/post/update/), for the fields the API will not give you --- # AI Memory With Tagging: Tags, Embeddings and Graph Edges Are Not Interchangeable Source: https://adityaarsharma.com/ai-memory-with-tagging/ MemPalace ships five storage backends. Its own backend table has a column headed Namespaces. Two of the five have a dash in that column, and one of those two is the default. `chroma`, the backend you get if you configure nothing, does not support namespaces. Neither does `sqlite_exact`. Milvus, Qdrant and pgvector do. Read from the MemPalace README on 3 September 2026. ![An embedding is a similarity score with no boolean. A tag is a boolean with no similarity.](https://adityaarsharma.com/wp-content/uploads/2026/09/a2b36eed-95ef-48c5-a806-a66f52281deb_2912x1632-scaled.png)That single table is the whole subject of this post. Tagging in AI memory systems is not a feature you turn on. It is a property of the storage layer underneath, and in most of these projects the tag you think you are writing is either metadata nobody filters on or a scoping key that only some backends honour. So: three retrieval strategies, what each can and cannot express, which projects expose tags at all, and the specific query shape where a tag beats a vector every time. ## Three strategies, and the thing each one cannot do Strip the branding off and there are exactly three ways these systems find the thing you asked for. | Strategy | What it matches on | What it returns | What it cannot express | | -------- | ------------------ | --------------- | ---------------------- | | Tags | Exact set membership | Everything carrying the label, unranked | Similarity. It has no idea that 'auth' and 'login' are related | | Embeddings | Cosine distance in vector space | The top k nearest, always k of them | Negation, exact sets, or 'none of these are relevant' | | Graph edges | Traversal from a start node | A path or a subgraph | Anything with no structural connection to your start point | ### The mechanism in one line each The mechanism is worth stating plainly, because it explains every failure you will hit. An embedding is a similarity score with no boolean. A tag is a boolean with no similarity. A graph edge is a boolean about a relationship, which is why it can answer 'how are these two connected' and cannot answer 'what did I say about tone of voice'. ### Vector search never says nothing matched Vector search will always return results. That is not a strength. Ask a vector index for everything tagged as superseded and it will hand you the ten nearest chunks to the word 'superseded', confidently, including nine that are current. There is no threshold below which it says nothing matched, unless you set one yourself and tune it. ## Which projects expose a tag at all I went through the documentation for each of these on 3 September 2026 looking for one thing: can I attach a label of my own choosing and then retrieve by that label. The answers vary more than the marketing does. ### Basic Memory: tags in the file, and observations indexed one by one The most complete tagging model in the field, and it is a text file. Frontmatter carries a `tags` list. Individual observations carry a bracketed category and inline hash tags. The documented pattern is `- [category] content #optional-tags (optional context)`, and the knowledge format docs state that categories can be anything that makes sense: decision, fact, preference, question, todo, risk, idea, with no fixed list. `--- tags: [auth, security, backend] --- ## Observations - [decision] Using JWT tokens for stateless authentication #security - [constraint] Tokens expire after 15 minutes (based on security audit)`![Basic Memory knowledge format docs showing frontmatter tags and bracketed observation categories](https://adityaarsharma.com/wp-content/uploads/2026/09/36369b08-0c24-4286-b8c0-3012c56a980b_2800x1720-scaled.png)The Basic Memory knowledge format docs, read 3 September 2026. Frontmatter carries a tags list, and each observation carries its own bracketed category.The line in those docs that matters most: each observation is indexed individually. So the retrieval unit is one bullet, not one file, and each bullet carries its own category and its own tags. That is a finer grain than any other project here offers, and it is the reason a hand-written taxonomy is workable in this one and painful in the others. It is also plain text, which means `grep` works, which means your tag query has an escape hatch that does not depend on the vendor's search implementation: Two lines cover most of it. `grep -rl 'tags:.*superseded' ~/basic-memory/` lists every note whose frontmatter carries that tag, and `grep -rn '^- \[risk\]' ~/basic-memory/` lists every observation filed under risk. ### MemPalace: a scope hierarchy, not free tags MemPalace does not give you arbitrary labels. It gives you a three-level structure: people and projects become wings, topics become rooms, and original content lives in drawers. The README's stated purpose for that structure is that searches can be scoped rather than run against a flat corpus, and the CLI exposes it, for example scoping a mining run per project with `--wing`. ![MemPalace README storage backend table with chroma and sqlite_exact showing no namespace support](https://adityaarsharma.com/wp-content/uploads/2026/09/a0e9f499-c20b-4383-bdbb-a028fb76f1f7_2800x1220-scaled.png)The MemPalace README backend table on GitHub, read 3 September 2026. chroma, the default, and sqlite_exact both carry a dash in the Namespaces column.A fixed hierarchy is a real trade against free tags. You cannot label one memory with six orthogonal labels. What you get instead is that the scope is enforced, so it cannot drift the way a free tag vocabulary always does. And because scoping happens before ranking, a search inside one wing is not competing against your entire history. The caveat is the backend table at the top of this post. If namespace support is what makes that scoping efficient at your data size, the default embedded ChromaDB is not the configuration you want. Switching is one environment variable, per the README: `MEMPALACE_BACKEND=qdrant MEMPALACE_QDRANT_URL=http://localhost:6333 mempalace search "why GraphQL" # backend and namespace support per the MemPalace README, read 3 September 2026`I compared MemPalace's storage model against mem0's in [the piece on why their benchmark numbers are not measuring the same quantity](https://adityaarsharma.com/mem0-vs-mempalace/), and the short version is that verbatim storage and extracted storage are different products wearing the same word. ### Supermemory and mem0: one scoping key, called something else Supermemory's README describes memory as scoped with projects, which it calls container tags, so you can separate work and personal context or organise by client or by repository. In the API that is a `containerTag` passed on both write and read. It is a tag, singular, and it partitions. mem0's README example passes `filters={"user_id": user_id}` into `memory.search()`. Same idea. A partition key with a filter applied at query time. Neither of these is a taxonomy. They are tenancy boundaries, and they solve the problem of not mixing two people's memories, which is a different problem from finding the right memory. ### Graphify: tags applied by the system, not by you Graphify is the interesting case, because it tags things without asking you to. Every edge in its graph carries a provenance marker: `EXTRACTED` if the relationship was explicit in the source, `INFERRED` if Graphify resolved it, `AMBIGUOUS` if it could not decide. You always know what was read directly and what was guessed. It also has a second, learned tag layer. `graphify save-result` records how a question and answer turned out, with an outcome of `useful`, `dead_end` or `corrected`. `graphify reflect --graph` then aggregates those into a lessons document and writes a work-memory overlay, `.graphify_learning.json`, which tags nodes as preferred, tentative or contested, recency-weighted and with provenance. `graphify save-result --question "Q" --answer "A" --nodes Foo Bar --outcome useful graphify reflect --graph graphify-out/graph.json # per the Graphify README, 3 September 2026`That is the most useful tag vocabulary in this whole field and almost nobody talks about it, because a tag that records how a piece of knowledge performed is worth more than a tag that records what topic it is about. I went through the rest of Graphify's design against its nearest competitor in [the codebase-memory-mcp and Graphify comparison](https://adityaarsharma.com/codebase-memory-mcp-vs-graphify/). ### Graphiti: the tag is time Graphiti attaches validity windows to facts rather than labels. When information changes, its README states that old facts are invalidated rather than deleted, so you can query what is true now or what was true at a point in time. Functionally that is a system-applied tag with two values, current and superseded, that you never have to write and cannot forget to apply. The price is the infrastructure: Neo4j, FalkorDB or Neptune, plus an OpenAI key by default. ### codebase-memory-mcp: no tag layer, typed nodes instead There is no user tagging in codebase-memory-mcp. What it gives you instead is a read-only openCypher subset over typed nodes, so the filter is the node label and the edge type rather than a label you invented: A supported query reads `MATCH (f:Function)-[:CALLS]->(g) WHERE f.name = 'main' RETURN g.name`, per the codebase-memory-mcp README read on 3 September 2026. For a code graph that is the right call. The types already exist in the source. Asking a developer to hand-tag functions would be inventing work. ## Where tagging wins, precisely A tag beats vector search whenever the set you want is defined by a decision you made rather than by what the text says. That is the rule, and it covers every real case: - **Provenance.** Everything that came from the client rather than from us. Nothing in the words distinguishes them.- **Status.** Everything superseded. A superseded note and a current note are near-identical in vector space, because they are about the same thing. That is exactly why the embedding cannot separate them.- **Tenancy.** Everything for one client, with a hard guarantee that nothing from another client can rank into the result. A similarity threshold is not a guarantee.- **Confidence.** Everything we are unsure about. This is what Graphify's tentative and contested markers are for.- **Negation.** Everything not in this category. There is no vector for 'not'. ### Where tagging loses And where it loses, which is most casual use. You have to remember the tag. You have to spell it the same way. Six months in you have `auth`, `authentication` and `login` as three separate labels covering one idea, and no search finds all three. Vector search has no such failure because it never needed you to be consistent. A tag vocabulary that nobody prunes is worse than no vocabulary, because it produces confidently incomplete results. ## The shape that works: filter, then rank The design that survives contact with real data is not a choice between the two. It is an ordering. Apply the boolean first, so the candidate set is correct, then rank within it by similarity, so the order is useful. ### Who already runs filter then rank Basic Memory's default already does a version of this: hybrid full-text plus vector ranking, with an optional cross-encoder reranking pass over the strongest candidates, both documented in its README and both off by default. Cognee's architecture is the same principle at a larger scale, combining a graph store with a vector store so structure narrows and embeddings order. If Cognee's mandatory `LLM_API_KEY` is what stops you using it, the honest replacements are in [the piece on what you are actually replacing when you leave Cognee](https://adityaarsharma.com/cognee-alternatives/). ![Qdrant filtering documentation showing must, should and must_not clauses](https://adityaarsharma.com/wp-content/uploads/2026/09/25fd11b5-600c-430d-9ce1-971e6afcede7_2800x1490-scaled.png)Qdrant's filtering documentation, read 3 September 2026. must, should and must_not are the boolean layer that runs before ranking. ### The failure mode: a tag nothing filters on What to avoid is a system where the tag exists in the file but nothing filters on it before ranking. That is decoration. You will write tags for a month, trust them, and get results that ignored them. ## One thing to check now Write two notes into whatever you are using. Identical text, different tag. Then run a search that should return exactly one of them. If both come back, your tags are metadata rather than a filter, and you should stop writing them or move to a backend that honours them. That test takes three minutes and it is the only one that settles it. The wider sort of this field by what each system returns is in [the piece on choosing a memory layer by retrieval unit](https://adityaarsharma.com/best-ai-memory-layer-for-agents/), the storage backends and licences per project are in [the platform comparison table](https://adityaarsharma.com/ai-memory-platform-comparison/), and everything here hangs off [the AI memory tools comparison](https://adityaarsharma.com/ai-memory-tools-compared/). ## Resources - [Basic Memory knowledge format](https://docs.basicmemory.com/concepts/knowledge-format) - the category and tag pattern, and the per-observation indexing note- [MemPalace on GitHub](https://github.com/MemPalace/mempalace) - the backend table with the Namespaces column- [MemPalace: wings, rooms and drawers](https://mempalaceofficial.com/concepts/the-palace.html) - how scoping is meant to be used- [Graphify on GitHub](https://github.com/Graphify-Labs/graphify) - save-result, reflect, and the preferred / tentative / contested overlay- [Graphiti on GitHub](https://github.com/getzep/graphiti) - validity windows and fact invalidation- [Supermemory on GitHub](https://github.com/supermemoryai/supermemory) - container tags as the scoping primitive- [Qdrant filtering documentation](https://qdrant.tech/documentation/concepts/filtering/) - what a pre-filter over a vector index actually does --- # block.json and the asset loading nobody configures Source: https://adityaarsharma.com/block-json-asset-loading/ Same post, same theme, same WordPress install. The only difference between these two numbers is one filter: | CSS on that one post | WordPress default | Block assets forced off | | -------------------- | ----------------- | ----------------------- | | `twentytwentyone/style.css` | 155,367 B | 155,367 B | | `twentytwentyone/assets/css/print.css` | 2,897 B | 2,897 B | | `block-library/style.min.css` | not sent | 140,761 B | | inline | 28,399 B | 18,515 B | | **Total CSS** | **186,663 B** | **317,540 B** | Measured on WordPress 7.1 with Twenty Twenty-One, no plugins, 2 September 2026. The only difference is the `should_load_separate_core_block_assets` filter. The post contains a paragraph, a heading, a list and a code block. Nothing else. In the second run WordPress sends 140,761 bytes of stylesheet covering all 115 core blocks so it can style four of them. That is 130,877 extra bytes of CSS on a page that needed roughly 13,000. The number people quote for this, 140,761 bytes, is real. I checked it two ways: `ls -l` on `wp-includes/css/dist/block-library/style.min.css` inside the WordPress 7.1 release zip, and a `curl` of the same file over HTTP from a running 7.1 install. Both give 140,761. The RTL build is 140,818. The unminified `style.css` is 164,440, which is what you get in any environment with `SCRIPT_DEBUG` on, including `wp-env` out of the box. What is not true any more is the claim wrapped around that number. Everything below was measured on 2 September 2026 against WordPress 7.1 in Docker, using `@wordpress/env` pinned to the 7.1 tag. ## The filter default is false. The default behaviour is not. Here is the function everyone points at, in `wp-includes/script-loader.php`: `function wp_should_load_separate_core_block_assets() { if ( is_admin() || is_feed() || wp_is_rest_endpoint() ) { return false; } return apply_filters( 'should_load_separate_core_block_assets', false ); }` ![The WordPress code reference page for wp_should_load_separate_core_block_assets, describing the combined wp-block-library stylesheet.](https://adityaarsharma.com/wp-content/uploads/2026/09/966ff098-5d38-4a76-98f3-a813adbbd439_2800x2000-scaled.png)developer.wordpress.org/reference/functions/wp_should_load_separate_core_block_assets/, screenshot taken 2 September 2026. The default argument is `false`, so reading that one function tells you the combined stylesheet always loads. It does not, because core itself adds filters before that function is ever called. ### Core adds the filters before that function ever runs - Block themes: `_add_default_theme_supports()` in `wp-includes/theme.php` adds `__return_true` to both `should_load_separate_core_block_assets` and `should_load_block_assets_on_demand`.- Classic themes: `wp_load_classic_theme_block_styles_on_demand()` does the same job. It is marked `@since 6.9.0` and moved to the `wp_default_styles` action at priority 0 in 7.0. The registration line in `default-filters.php` carries its own comment: it must run before `wp_default_styles()` and `register_core_block_style_handles()`. That is why it sits at priority 0. So since WordPress 6.9, a classic theme gets on-demand block styles too, unless it opts out. The function bails early if `wp_is_block_theme()` is true, then checks `wp_should_output_buffer_template_for_enhancement()` and bails if the site has turned the template output buffer off. That gate matters: the on-demand path needs the buffer, because block styles are discovered while blocks render and then hoisted from the footer into the head by `wp_hoist_late_printed_styles()`. ### What I measured, rather than read I confirmed the outcome rather than trusting the reading. On WordPress 7.1 with Twenty Twenty-One, a classic theme, no plugins, no filters of mine: the combined stylesheet does not load. On the same install with the two filters forced back to false, it does. That is the pair of measurements at the top of this post. ## What a real page loads when on-demand is on Here is every stylesheet on that same post, with `SCRIPT_DEBUG` off so the numbers are the ones a visitor gets: | Handle | Bytes | | ------ | ----- | | `wp-block-library-inline-css` (common.css) | 4,274 | | `wp-block-search-inline-css` | 2,521 | | `wp-block-latest-posts-inline-css` | 2,316 | | `wp-block-latest-comments-inline-css` | 1,374 | | `wp-block-heading-inline-css` | 1,247 | | `wp-block-paragraph-inline-css` | 739 | | `wp-block-categories-inline-css` | 403 | | the `core/code` block handle | 253 | | `wp-block-group-inline-css` | 197 | | `wp-block-list-inline-css` | 174 | | `wp-block-archives-inline-css` | 172 | | **Total core block CSS** | **13,670** | | `global-styles-inline-css` (theme.json output) | 13,904 | Every stylesheet on the same post with `SCRIPT_DEBUG` off, WordPress 7.1, 2 September 2026. Zero extra HTTP requests. Every one of those was inlined into the head. The search, latest posts, latest comments, categories and archives entries are the theme's sidebar widgets, not the post. 13,670 bytes against 140,761 is a factor of ten, and it is the default now. ### Inlining has a 40,000 byte budget Inlining has a budget. `wp_maybe_inline_styles()` sets `$total_inline_limit = 40000`, filterable through `styles_inline_size_limit`, and the docblock records that the default went from 20K to 40K in 6.9. Stylesheets are sorted by size and inlined until the budget runs out; the rest get a link tag. On a Twenty Twenty-Five page on the same 7.1 install, the Navigation block's stylesheet, 20,776 bytes minified, was the only one that came over the wire as a file. Everything else on the page was inlined. ![Bar chart of style.min.css size per core block in WordPress 7.1, navigation at 20,776 bytes down to paragraph at 655 bytes.](https://adityaarsharma.com/wp-content/uploads/2026/09/5f55d371-d02a-483d-9b86-3e7487a1d097_2912x1632-scaled.png)Built from the per-block file sizes in this post, read from the WordPress 7.1 release zip on 2 September 2026. ## Why the big file got bigger | File | WordPress 6.9.7 | WordPress 7.1 | | ---- | --------------- | ------------- | | `block-library/style.min.css` | 119,358 bytes | 140,761 bytes | | `block-library/style.css` | 128,165 bytes | 164,440 bytes | | `block-library/style-rtl.min.css` | 119,140 bytes | 140,818 bytes | Read from the two release zips on 2 September 2026. The combined stylesheet grew 21,403 bytes across two release cycles, which is the argument for the on-demand default in a single line: the file that covers every block only ever gets bigger, and no individual site uses more of it than it did before. ### Where the weight sits, block by block Where the weight sits, per block, in the 7.1 minified builds: | Block | style.min.css | | ----- | ------------- | | `core/navigation` | 20,776 bytes | | `core/cover` | 20,316 bytes | | `core/gallery` | 16,280 bytes | | `core/social-links` | 11,762 bytes | | `core/playlist` | 9,443 bytes | | `core/image` | 8,811 bytes | | `core/table` | 3,908 bytes | | `core/button` | 2,559 bytes | | `core/heading` | 1,165 bytes | | `core/paragraph` | 655 bytes | | `core/code` | 174 bytes | | `core/list` | 95 bytes | 87 of the 115 core block directories ship a `style.min.css`, and they total 135,164 bytes. A long-form article using paragraph, heading, list, quote, image, code and separator needs 12,004 bytes of that. Navigation and Cover alone are 41,092 bytes, and a post page usually renders neither. ![Bar chart comparing block-library/style.min.css at 119,358 bytes in WordPress 6.9.7 and 140,761 bytes in 7.1.](https://adityaarsharma.com/wp-content/uploads/2026/09/62be4535-70fa-4a2c-8de3-95772444a0b5_2912x1632-scaled.png)Built from the release zip figures in this post, read on 2 September 2026. ## The five asset fields, and which one leaks This is the part nobody configures, because five of the fields look interchangeable in the docs and are not. `register_block_type_from_metadata()` in `wp-includes/blocks.php` maps them like this: | block.json field | WP_Block_Type property | Enqueued where | | ---------------- | ---------------------- | -------------- | | `editorScript` | `editor_script_handles` | editor only | | `editorStyle` | `editor_style_handles` | editor only | | `script` | `script_handles` | editor and front end | | `style` | `style_handles` | editor and front end | | `viewScript` | `view_script_handles` | front end, on render | | `viewScriptModule` | `view_script_module_ids` | front end, on render, as a module | | `viewStyle` | `view_style_handles` | front end, on render | ![The WordPress block editor handbook page for metadata in block.json, showing the schema and field list.](https://adityaarsharma.com/wp-content/uploads/2026/09/6d5d3a55-4c8c-404b-b85f-6f5a8c312c35_2800x2000-scaled.png)developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/, screenshot taken 2 September 2026. ### The words on render are doing all the work The words "on render" are doing all the work. The `view*` handles are enqueued from inside `WP_Block::render()`, so they only appear when the block appears. The `script` and `style` handles are enqueued from `wp_enqueue_registered_block_scripts_and_styles()`, which loops every registered block type on the `enqueue_block_assets` hook. That loop opens with an early return: if `wp_should_load_block_assets_on_demand()` is true it stops there. If it is false, it walks `$block_registry->get_all_registered()` and calls `wp_enqueue_style()` on every `style_handle` and `wp_enqueue_script()` on every `script_handle` it finds. ### I proved the leak instead of inferring it So when on-demand is off, every registered block's `script` and `style` go on every page whether the block is there or not. I proved it rather than inferring it. I scaffolded a block, gave it a `script` as well as a `viewScript`, and loaded a post that does not contain the block: - **On-demand off, page without the block:** `aditya-code-copy-style-inline-css` and `aditya-code-copy-script-js` both present.- **On-demand on, the WordPress default, page without the block:** nothing.- **On-demand on, page with the block:** the style, the script and `aditya-code-copy-view-script-js`. `viewScript` never leaked in either configuration, because it is not in that loop at all. `script` and `style` leaked in the configuration that is still one badly chosen plugin filter away. That is the answer to why your CSS loads on every page. It is not a bug in your block. It is the `style` field doing what it says, on a site where something turned on-demand loading off. Search your plugins for `should_load_separate_core_block_assets` and `should_load_block_assets_on_demand` before you blame anything else. ## viewScript gets defer for free. script does not. There is one more thing in `register_block_script_handle()` that is easy to miss: `$script_args = array(); if ( 'viewScript' === $field_name && $script_uri ) { $script_args['strategy'] = 'defer'; }` Only `viewScript` gets it. On the front end of my test post, `aditya-code-copy-script-js` comes out as a plain `<script src>` tag. `aditya-code-copy-view-script-js` comes out with `data-wp-strategy="defer"` and a real `defer` attribute. Same plugin, same build, same directory. One blocks the parser and one does not, decided entirely by which key you typed in block.json. If you have front-end JavaScript in `script` because a tutorial from 2020 put it there, moving it to `viewScript` is a two character edit that removes a render-blocking request from every page and stops the file loading on pages that do not use the block. ### viewScriptModule, and when to reach for it `viewScriptModule` is the third option, core since 6.5.0. It goes through `register_block_script_module_id()` and is enqueued with `wp_enqueue_script_module()` rather than `wp_enqueue_script()`, which means a real ES module with import maps rather than a global. It is what the Interactivity API uses. Use it for new front-end code and keep `viewScript` for anything that has to run in an environment you do not control. ## Check your own site in one command To count the CSS bytes on a URL yourself, pull the page with `curl -sL`, extract every `href` ending in `.css`, fetch each one through `wc -c`, and add the length of every `<style>` block in the HTML. Files plus inline is the number that matters, rather than the request count. Then the one-line diagnosis: `curl -sL "$URL" | grep -c "block-library/style"` ### Reading the result A 1 means something on your site has turned off separate block assets and you are shipping the whole 140,761 byte file. A 0 means you are on the modern path. That check takes ten seconds and it is the first thing I would run on a site that feels heavy for no visible reason, before touching anything in [the usual media query and CSS specificity rabbit hole](https://adityaarsharma.com/how-to-fix-media-query-not-working-in-wordpress/). ## The failure modes worth knowing - `is_admin()`, `is_feed()` and `wp_is_rest_endpoint()` short-circuit both functions to false before any filter runs. The editor always gets the combined stylesheet, which is why a page can look right in the editor and lose styles on the front end.- A classic theme that turns off the template enhancement output buffer loses on-demand loading entirely, because `wp_load_classic_theme_block_styles_on_demand()` returns early when `wp_should_output_buffer_template_for_enhancement()` is false. Anything that streams the response will do this.- The two filters are added at priority 0 specifically so a theme can override them at any normal priority. That is deliberate, and it is also why a plugin adding `__return_false` at priority 10 wins silently.- Registering a block on a hook later than `init` means `register_core_block_style_handles()` has already run and your handles may not exist when the enqueue loop reaches them.- `wp-env` sets `SCRIPT_DEBUG` to true in the development environment, so every CSS size you read there is the unminified one. Set it to false in `.wp-env.json` before you measure anything. ## One thing to do in the next ten minutes Open the `block.json` of every block you ship and look at the `script` key. If there is one, ask whether that code needs to run in the editor. If it does not, rename the key to `viewScript`, rebuild, and load a page that does not contain the block. The file should be gone from the source. That is the highest-value ten minutes in this whole post, and it costs you nothing but a rebuild. The wider context for why so much of this moved at once is in [the writeup on WordPress 7.0 being the biggest change since Gutenberg](https://adityaarsharma.com/wordpress-7-0-is-the-biggest-thing-to-happen-since-gutenberg-and-nobodys-talking-about-it/). If you are chasing page weight rather than CSS specifically, [bulk image compression](https://adityaarsharma.com/how-to-compress-wordpress-images-in-bulk/) is usually the larger win, and [the client site management notes](https://adityaarsharma.com/how-to-manage-wordpress-websites-for-clients/) cover how to keep it from coming back. ## Resources - [wp_should_load_separate_core_block_assets()](https://developer.wordpress.org/reference/functions/wp_should_load_separate_core_block_assets/) in the code reference.- [wp_should_load_block_assets_on_demand()](https://developer.wordpress.org/reference/functions/wp_should_load_block_assets_on_demand/), added in 6.8.0, the function that decides on-render loading.- [Metadata in block.json](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/), the reference for every asset field.- [wp-includes/script-loader.php in wordpress-develop](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/script-loader.php), where the two functions, the inline size limit and the enqueue loop all live.- [WordPress Developer Hours: Styling Blocks](https://wordpress.tv/2023/07/26/wordpress-developer-hours-styling-blocks-july-2023/) on WordPress.tv, with Michael Burridge, Justin Tadlock and Ryan Welcher, 26 July 2023. It covers CSS custom properties in blocks; it predates the 6.9 loading changes, so read it for the styling and not the loading. ## More on the block editor - [The Abilities API in WordPress Core: What It Is and What It Is Not](https://adityaarsharma.com/abilities-api-wordpress-core/)- [Building a Gutenberg Block in 2026, From an Empty Directory](https://adityaarsharma.com/gutenberg-block-2026/)- [theme.json Controls Your Output More Than Your Theme Does](https://adityaarsharma.com/theme-json-controls-your-output/) --- # The Scanner Said Clean. The Site Was Serving Spam to Googlebot. Source: https://adityaarsharma.com/the-scanner-said-clean-the-site-was-serving-spam-to-googlebot/ The site looked fine. Loaded normally in a browser. Admin dashboard clean. Two commercial security plugins installed, both reporting no threats. The client had been paying for one of them for two years. Search Console said otherwise. Pages indexed that did not exist. Japanese pharmaceutical keywords on a business services site. That gap, between what the scanners saw and what Google saw, is the whole story. It is also the reason I ended up writing an agent instead of buying a tool. ## Why every scanner passed Three reasons, and each one is a design decision rather than a bug. ### They fetch as themselves The payload was cloaked. It checked the user agent and served spam only to Googlebot and a couple of other crawler signatures. A scanner requesting the page as itself gets the clean version, every time, correctly, forever. ### They scan wp-content and stop That is where malware usually lives, so that is where they look. This one had a loader in a PHP file two directories above the web root, well outside the WordPress install. Not in `wp-content`. Not in core. Not scanned. ### They match signatures The file was assembled at runtime from three fragments that individually look like ordinary configuration handling. No single fragment matches anything. The assembled behaviour is the payload, and it only exists in memory. None of that is exotic. It is standard for anything written after about 2020. The scanners are not bad. They are answering a question that stopped being the right question. ## Finding it by hand I found it by asking a different question: not "is there malware in the files" but "does this site serve different content to Googlebot than to me." Fetched every URL twice, once as a browser and once as Googlebot, and diffed. Fourteen URLs came back different. All fourteen had injected link blocks visible only to the crawler. From there it was tracing backwards. Which file writes that block, what includes that file, what wrote that file, and when. The entry point turned out to be an abandoned plugin with a known unauthenticated file upload vulnerability, still installed, still active, deactivated in the client's memory but not in the database. Total time: most of a day. Most of it mechanical. ## Why an agent instead of a script The mechanical parts were scriptable. The judgment parts were not. "Is this obfuscated code malicious or just minified" is a judgment call. So is "does this file belong in this directory," and "is this modification to a core file a hack or a legitimate patch someone applied in 2019 and forgot." A script has to encode those rules in advance. It cannot, so it either flags everything and you drown in noise, or it flags nothing and you are back where you started. An agent can reason about it, and more importantly it can go and check. It can look at the file, look at when it was written, look at what else was written in the same minute, look at whether the plugin it belongs to is even installed, and then decide. So I wrote it as an MCP server. Read-only by default. It reports and does not act unless you tell it to. **What it does:** - Fetches every page twice, as browser and as Googlebot, and diffs. This is the check that caught the original infection, and the one no commercial scanner performs - Scans the entire server, not just `wp-content` - Reads file modification times as a cluster, because an infection writes many files in one burst and that burst is the most legible signal on a compromised server - Traces the entry point: which vulnerable component, which version, which known CVE - Backs up before touching anything, with automatic rollback if a cleanup breaks the site Open source and free. Thirty stars at the time of writing, which is small, and I would rather give the real number than gesture at traction. ## What broke in production Three things, all of which taught me more than the parts that worked. ### False positives on legitimate obfuscation Some commercial plugins ship minified and encoded code. It looks exactly like a payload. Early runs flagged a licensing module on every site that had it. The fix was checking file provenance, does this belong to an installed plugin at the expected path with a matching checksum, before judging the contents. ### Cleanup broke a site A modified core file was hosting an injection. The agent removed the injection. The file also contained a legitimate patch the previous developer had applied directly to core, undocumented. Removing the injection removed the patch. Site broke. Rollback fired and restored it, which is the only reason this is an anecdote rather than an incident. That is why the default is read-only now. **Detection and remediation are different risk profiles and should be different decisions.** ### Confident wrong answers on ambiguity Asked whether a specific file was malicious, the agent would commit to a verdict with no hedging even when the evidence was genuinely thin. This is the failure mode that worries me most in agent tooling generally, because the confidence is indistinguishable from the confidence on cases it gets right. The mitigation is structural rather than prompt-level: the tool returns evidence and a confidence band, not a verdict. The human decides. If the tooling wants to state a conclusion, it has to show what the conclusion rests on. ## The part that generalises There is a lesson here that is not about malware. The scanners were not broken. They were built to answer "are there known-bad files on this server," and they answer it well. The question that mattered was "does this server behave differently for different visitors," and nobody was asking it because it is expensive and stateful and does not fit a signature database. Most of the tooling I have replaced with agents in the last year fits that shape. The old tool answers the tractable question correctly. The agent can afford to ask the expensive question, because asking it is now cheap. That is the actual shift. Not that AI is smarter than the scanner. That a whole class of questions that were too costly to ask at scale are now trivial to ask. If you run WordPress sites and have not checked what you serve to Googlebot lately, check. It takes a minute and the answer is occasionally unpleasant. --- # Running Claude Code Against WordPress: The Complete Setup Source: https://adityaarsharma.com/running-claude-code-against-wordpress-the-complete-setup/ There is a thread on r/Wordpress asking whether anyone runs Claude Code against WordPress in a real way. 118 upvotes. 153 comments. The best answer in it is a Google Doc. Underneath that answer, someone wrote: *"I am very intrigued by this concept but understand 10% of this comment."* Twenty-three people upvoted them. I have been running agents against production WordPress for about six months. Plugins on 500,000+ installs, client sites, my own servers. This is the writeup that thread needed. ## The stack Four MCP servers plus the CLI. Nothing exotic. ### wp-cli over SSH The single highest-leverage piece. Everything WordPress can do from the command line, the agent can do. Plugin state, user capabilities, transients, cron, search-replace across the database, revision cleanup. If you give an agent one tool for WordPress, give it this. ### A PHP execution bridge I use one that exposes `execute-php` inside the WordPress context. This is the dangerous one and I come back to it below. ### Playwright For anything visual or interaction-dependent. Whether a modal actually opens, whether a form actually submits, whether the layout survives at 375px. ### A crawler I built mine as an MCP server because I was tired of exporting CSVs and pasting them into a chat. Internal linking, orphan pages, redirect chains, schema validation. The agent calls it and reasons over the result in the same context. ## The guardrails matter more than the stack I learned this the expensive way. In August I lost hours of work to a `git reset --hard` that an agent ran because it seemed like the reasonable way to get back to a clean state. It was reasonable. It was also destructive, and the reflog only tells you what happened after you have already lost the afternoon. So now there is a hook that blocks it. Sixteen rules, all at the tool layer, all firing before execution: - `git reset --hard`, `reset HEAD~`, `branch -D`, `clean -fd`, `rm -r` on tracked paths - `gh repo create` without `--private`, because the default is public - `docker compose down -v` on any stack the agent did not create, because named volumes do not come back - `rm -rf` anywhere near a home directory The pattern that generalises: **the agent will occasionally choose a destructive path because it is the shortest correct path.** Instruction-level prevention does not hold. Prompts get overridden by context. A hook that returns a non-zero exit code does not. If you take one thing from this post, take that. Put the guardrail in the tool layer, not the prompt. ## Four things that break ### 1. Serialized data and string length Page builder content lives in `_elementor_data` as a JSON blob in `wp_postmeta`. Agents handle this fine, mostly. The trap is PHP-serialized data elsewhere. I did a find-and-replace across a client site recently, swapping one email address for another everywhere it appeared. Straightforward. The new address was one character longer than the old one. PHP serialization stores string lengths inline: `s:30:"..."`. Change the string without changing the declared length and the whole structure becomes unreadable. A raw `str_replace` across `wp_options` would have silently corrupted every serialized setting on the site. The fix is boring and absolute: for serialized data, `get_option` into PHP, walk the structure, `update_option` back and let WordPress reserialize. Raw string replacement only where the payload is JSON or plain text. I built a length check into the script that aborts if the replacement differs in length and the target might be serialized. It fired. That check saved the site. ### 2. Lazy-loaded admin UI lies to a headless browser This one cost me a wrong diagnosis in front of a client. I audited a page with Playwright, queried the DOM for a set of sections, and got nothing back. Reported them as missing. Recommended building them. They existed. They were lazy-loaded, and in an automated context with no real scroll behaviour they never rendered. My query was correct and my conclusion was garbage. **Not rendered is not the same as not present.** Now I check a second way before I claim absence: the dedicated detail route, the REST response, or the database directly. Whichever is authoritative rather than whichever is visible. This is the WordPress instance of a general agent problem. The model reports what the tool returned. If the tool has a blind spot, the model inherits it with full confidence and no hedging. ### 3. Rich text editors reject programmatic input Modern editors are ProseMirror or TipTap under the hood. They keep internal document state and ignore anything that does not look like real input. Setting `.value` does nothing. Playwright's `fill()` appended instead of replacing, so I ended up with the old content and the new content concatenated, and a character counter reading 276 out of a 220 limit. What works is `document.execCommand('selectAll')` followed by `execCommand('insertText')`. Deprecated, still the reliable path, because it produces the input events the editor is actually listening for. Separately, React-controlled inputs reject programmatically set values outright. The fix is the native prototype setter for the correct element type, followed by a bubbling input event. Use `HTMLTextAreaElement.prototype` for a textarea, not `HTMLInputElement.prototype`. I lost an hour to exactly that mismatch: the DOM value changed, React state did not, and every save sent the old data. ### 4. Cache invalidation has more layers than you think Change `_elementor_data` and the change is live in the database and invisible on the site. The order that actually works: - `delete_post_meta` for `_elementor_css` and `_elementor_page_assets` - `Elementor\Plugin::$instance->files_manager->clear_cache()` - `wp_cache_flush()` - Host-level page cache - CDN purge - Delete revisions, because they carry stale copies that resurface Skip a layer and you get the worst possible outcome: it works for you and not for the client, and you spend an hour arguing about what you can both see. ## What actually changed The measurable difference is not that individual tasks got faster. It is that the follow-up question became free. Old flow: run the crawl, export, filter, find something interesting, go back for more context, re-explain. Every follow-up cost a round trip. New flow: "now check whether those pages are in the sitemap," "now show me which have inbound internal links," "now check if any are noindexed." Each of those is one sentence against context the agent already holds. That is the actual unlock. Not speed on the first answer. Cheapness on the fourth. ## What I would tell the person in that thread Start with wp-cli over SSH and nothing else. Learn what an agent does well and badly against a real site before adding surface area. Add the destructive-command hook before you add the second tool. Not after your first bad afternoon. Never let an agent touch a production database without a snapshot in the same session. Not a nightly backup. A snapshot you took two minutes ago. Verify visual claims twice, through two different mechanisms. The agent is confident either way. And accept that a meaningful share of your time moves from doing the work to specifying the work precisely enough that the agent does it correctly. That is a real cost. It is still worth it, but nobody selling you on this mentions it. --- # Add a “Go to Settings” Link After Installing Any WordPress Plugin (Full Code) Source: https://adityaarsharma.com/go-to-plugin-settings-link-after-install/ **The problem:** you install or update a plugin, WordPress hands you “Activate Plugin” and “Return to Plugin Installer” — and then leaves you to *hunt* for where that plugin’s settings actually live. This started as an answer to a question Rodolfo Melogli raised: after installing a plugin from a ZIP, why isn’t there a “Go to plugin settings” link, with a fallback to the Plugins page? Here is the **complete snippet** that does it — and does it for *any* plugin, in any language, without a hardcoded list. Grab the full code here: [the snippet on GitHub Gist](https://gist.github.com/adityaarsharma/46cec46c9dac76afa395866bb43fc805). *Last updated: July 2026.* ## Why the obvious fixes don’t hold up Every “just add a settings link” approach I tried first broke somewhere. These are the four dead ends, because the fix only makes sense once you’ve seen them fail: - **Hardcode a table of plugin → settings URL.** Fine for the top 20, useless for the long tail, and it drifts the moment a plugin renames its page. - **Match the word “Settings” in the admin menu.** Dies on any non-English admin, and false-matches WordPress’ own *Settings* menu. - **Guess `admin.php?page={slug}`.** Wrong for a large share of plugins. Akismet lives at `options-general.php?page=akismet-key-config`, ACF at `edit.php?post_type=acf-field-group`, Redirection under *Tools*. A guessed URL that 404s is worse than no link at all. - **Use `install_plugin_overwrite_actions`.** Tempting name, wrong hook — it fires on the overwrite *confirmation* screen, not the install/update *result*. The right hook is `install_plugin_complete_actions`, which fires after a normal install, an update, *and* a ZIP upload. ## The one reliable signal: the plugin’s own settings link Every plugin that has a settings page already adds its own link to its row on the Plugins list, through the `plugin_action_links_{$plugin_file}` filter. That link is authored by the plugin, points at the correct URL, and is completely independent of the admin language. So instead of guessing, the snippet **reads that**. It fires the filter, parses the returned HTML with `DOMDocument` (not regex), keeps only internal `wp-admin` URLs, throws away the core row-actions (activate / deactivate / delete / editor), and prefers the candidate whose URL contains the plugin’s own slug. Whatever survives is the plugin’s real settings URL — ground truth, not a guess. ## Learn it once, cache it, let it self-heal The catch: that filter only returns anything while the plugin is **loaded** (active). So the snippet learns the link at the two moments it can, and remembers it: - on `activated_plugin` — the instant a plugin activates; - on `load-plugins.php` — where every active plugin’s action links exist, so it back-fills the whole active set. Both write to a single option keyed by plugin file. Resolution priority is **learned cache → seed map → live best-effort**. The seed map (~22 popular plugins) is only a bootstrap so the very first click is right before the cache warms; the learned cache overrides it, so a stale seed URL self-corrects the first time you open the Plugins screen. Only the reliable action-link signal is ever cached — the fuzzy menu scan is used live but never stored, so it can’t poison the cache. ## Never show a dead link: activate first, then resolve After a ZIP install the plugin isn’t active yet, so its settings page doesn’t exist and its own link can’t be read. A “Go to settings” link there would be dead. So the result screen branches: - **Already active** (you just updated it) → *Go to settings*, straight to the resolved URL. - **Freshly installed, not active** → *Activate & open settings*: one nonce-protected click that activates the plugin, reads its now-available real link, and redirects you there. - **Always** → *Go to Plugins page*. The privileged step sits behind `admin_post` + `check_admin_referer` + a capability check, so a logged-out or low-role user can’t trigger anything. Here is the whole flow: `install / update result screen | Is the plugin active? |-- yes --> resolve URL (cache > seed > live) --> "Go to settings" |-- no --> "Activate & open settings" | --> activate_plugin() --> read the plugin's OWN link --> redirect there | always --> "Go to Plugins page" get_settings_url( plugin_file ): 1. learned cache (the plugin's own link, saved on activate + on the Plugins screen) 2. seed map (22 popular plugins - bootstrap only, cache overrides it) 3. live best-effort (read the action link now; else a strict full-slug menu scan)` ## The complete snippet ``` <?php /** * Plugin Name: Smart "Plugin Installed" Links * Description: After installing/updating a plugin (incl. ZIP uploads), adds a correct "Go to settings" * link + a reliable "Go to Plugins page" link. LEARNS each plugin's real settings link (from * the plugin's OWN declared Settings action link) on activation and on the Plugins screen, so * it is self-correcting, version-proof and language-independent. No English keyword matching. * Version: 2.1.0 * Author: Aditya Sharma * License: GPL-2.0-or-later * * Resolution priority: learned cache > seed map > live best-effort. Only high-confidence signals are cached. */ if ( ! defined( 'ABSPATH' ) ) { exit; } // Guard so the same code is safe whether dropped in as a plugin, mu-plugin, or theme functions.php snippet. if ( ! class_exists( 'Aditya_Plugin_Install_Links' ) ) : final class Aditya_Plugin_Install_Links { const OPTION = 'aditya_plugin_settings_urls'; // [ plugin_file => admin-relative path ] public static function init() { add_filter( 'install_plugin_complete_actions', array( __CLASS__, 'install_actions' ), 10, 3 ); add_action( 'activated_plugin', array( __CLASS__, 'learn_one' ), 999, 1 ); add_action( 'load-plugins.php', array( __CLASS__, 'learn_active' ) ); add_action( 'admin_post_aditya_activate_goto_settings', array( __CLASS__, 'handle_activate_goto_settings' ) ); } /** * Seed map — bootstraps top plugins BEFORE their first activation. The learned cache (each plugin's * OWN link) overrides these, so any drift self-heals. Admin-relative paths. Extend freely. */ public static function seed_map() { return array( 'woocommerce/woocommerce.php' => 'admin.php?page=wc-settings', 'wordpress-seo/wp-seo.php' => 'admin.php?page=wpseo_dashboard', 'elementor/elementor.php' => 'admin.php?page=elementor', 'contact-form-7/wp-contact-form-7.php' => 'admin.php?page=wpcf7', 'akismet/akismet.php' => 'options-general.php?page=akismet-key-config', 'classic-editor/classic-editor.php' => 'options-writing.php', 'jetpack/jetpack.php' => 'admin.php?page=jetpack', 'wpforms-lite/wpforms.php' => 'admin.php?page=wpforms-settings', 'all-in-one-seo-pack/all_in_one_seo_pack.php' => 'admin.php?page=aioseo-settings', 'wordfence/wordfence.php' => 'admin.php?page=Wordfence', 'seo-by-rank-math/rank-math.php' => 'admin.php?page=rank-math', 'litespeed-cache/litespeed-cache.php' => 'admin.php?page=litespeed-cache', 'wp-super-cache/wp-cache.php' => 'options-general.php?page=wpsupercache', 'w3-total-cache/w3-total-cache.php' => 'admin.php?page=w3tc_general', 'wp-rocket/wp-rocket.php' => 'options-general.php?page=wprocket', 'updraftplus/updraftplus.php' => 'options-general.php?page=updraftplus', 'wp-mail-smtp/wp_mail_smtp.php' => 'admin.php?page=wp-mail-smtp', 'google-site-kit/google-site-kit.php' => 'admin.php?page=googlesitekit-settings', 'google-analytics-for-wordpress/googleanalytics.php' => 'admin.php?page=monsterinsights_settings', 'wp-smushit/wp-smush.php' => 'admin.php?page=smush', 'redirection/redirection.php' => 'tools.php?page=redirection.php', 'advanced-custom-fields/acf.php' => 'edit.php?post_type=acf-field-group', ); } /** Absolute settings URL or '' if unknown. Priority: learned cache > seed > live best-effort. */ public static function get_settings_url( $plugin_file ) { if ( empty( $plugin_file ) ) { return ''; } $cache = get_option( self::OPTION, array() ); if ( is_array( $cache ) && ! empty( $cache[ $plugin_file ] ) ) { return admin_url( $cache[ $plugin_file ] ); } $seed = self::seed_map(); if ( isset( $seed[ $plugin_file ] ) ) { return admin_url( $seed[ $plugin_file ] ); } $rel = self::live_detect( $plugin_file ); return $rel ? admin_url( $rel ) : ''; } /** Learn just-activated plugin (cache ONLY the reliable action-link signal). */ public static function learn_one( $plugin_file ) { $rel = self::settings_from_action_links( $plugin_file ); if ( $rel ) { self::cache_set( $plugin_file, $rel ); } } /** On the Plugins screen, every active plugin's action links exist → cache them (reliable signal only). */ public static function learn_active() { if ( ! current_user_can( 'activate_plugins' ) ) { return; } $cache = get_option( self::OPTION, array() ); $cache = is_array( $cache ) ? $cache : array(); $changed = false; foreach ( (array) get_option( 'active_plugins', array() ) as $file ) { if ( ! empty( $cache[ $file ] ) ) { continue; } $rel = self::settings_from_action_links( $file ); if ( $rel ) { $cache[ $file ] = $rel; $changed = true; } } if ( $changed ) { update_option( self::OPTION, $cache, false ); } } private static function cache_set( $file, $rel ) { $cache = get_option( self::OPTION, array() ); $cache = is_array( $cache ) ? $cache : array(); $cache[ $file ] = $rel; update_option( self::OPTION, $cache, false ); } /* ------------------------------------------------------------------ * * Detection * ------------------------------------------------------------------ */ /** RELIABLE signal (cacheable): the plugin's OWN declared "Settings" action link. Language-independent. */ private static function settings_from_action_links( $plugin_file ) { $links = array(); $links = (array) apply_filters( "plugin_action_links_{$plugin_file}", $links, $plugin_file, array(), 'all' ); $links = (array) apply_filters( "network_admin_plugin_action_links_{$plugin_file}", $links, $plugin_file, array(), 'all' ); $admin = admin_url(); $net = network_admin_url(); $needles = self::slug_needles( $plugin_file ); $candidates = array(); foreach ( $links as $html ) { if ( ! is_string( $html ) || false === stripos( $html, 'href' ) ) { continue; } $dom = new DOMDocument(); libxml_use_internal_errors( true ); $dom->loadHTML( '<?xml encoding="utf-8"?><div>' . $html . '</div>' ); libxml_clear_errors(); foreach ( $dom->getElementsByTagName( 'a' ) as $a ) { $href = html_entity_decode( trim( $a->getAttribute( 'href' ) ), ENT_QUOTES ); if ( '' === $href || '#' === $href ) { continue; } if ( 0 !== strpos( $href, 'http' ) && 0 !== strpos( $href, '/' ) ) { $href = $admin . ltrim( $href, '/' ); } if ( 0 !== strpos( $href, $admin ) && 0 !== strpos( $href, $net ) ) { continue; // internal admin URLs only } if ( preg_match( '#(plugins\.php\?|plugin-editor\.php|action=(activate|deactivate|delete)|update\.php|tgmpa)#i', $href ) ) { continue; // skip core plugin-row actions } $candidates[] = $href; } } if ( empty( $candidates ) ) { return ''; } // Prefer a candidate whose URL mentions the plugin's full slug; else the first custom link (conventionally Settings). foreach ( $candidates as $c ) { foreach ( $needles as $n ) { if ( false !== stripos( $c, $n ) ) { return self::to_relative( $c ); } } } return self::to_relative( $candidates[0] ); } /** BEST-EFFORT (live only, never cached): strict full-slug menu scan. */ private static function settings_from_menu( $plugin_file ) { $needles = self::slug_needles( $plugin_file ); global $submenu, $menu; foreach ( array( (array) $submenu, (array) $menu ) as $group ) { foreach ( $group as $items ) { foreach ( self::rows( $items ) as $row ) { $page = isset( $row[2] ) ? (string) $row[2] : ''; if ( '' === $page ) { continue; } foreach ( $needles as $n ) { if ( false !== stripos( $page, $n ) ) { return ( false !== strpos( $page, '.php' ) ) ? $page : 'admin.php?page=' . $page; } } } } } return ''; } private static function live_detect( $plugin_file ) { $rel = self::settings_from_action_links( $plugin_file ); return $rel ? $rel : self::settings_from_menu( $plugin_file ); } /** Full-slug needles only (NOT generic tokens) — avoids "editor" matching site-editor.php etc. */ private static function slug_needles( $plugin_file ) { $slug = ( false !== strpos( $plugin_file, '/' ) ) ? dirname( $plugin_file ) : basename( $plugin_file, '.php' ); $slug = sanitize_title( $slug ); if ( strlen( $slug ) < 4 ) { return array(); } return array_values( array_unique( array( $slug, str_replace( '-', '_', $slug ), str_replace( '-', '', $slug ) ) ) ); } private static function rows( $items ) { return ( isset( $items[2] ) && is_string( $items[2] ) ) ? array( $items ) : (array) $items; } private static function to_relative( $url ) { $admin = admin_url(); if ( 0 === strpos( $url, $admin ) ) { return substr( $url, strlen( $admin ) ); } $net = network_admin_url(); if ( 0 === strpos( $url, $net ) ) { return substr( $url, strlen( $net ) ); } return $url; } /* ------------------------------------------------------------------ * * The install / update result screen * ------------------------------------------------------------------ */ public static function install_actions( $actions, $api, $plugin_file ) { if ( ! current_user_can( 'activate_plugins' ) ) { return $actions; } if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $sep = '<span aria-hidden="true">|</span>'; $is_active = $plugin_file && is_plugin_active( $plugin_file ); $can_set = $plugin_file && current_user_can( 'manage_options' ); if ( $can_set && $is_active ) { // Plugin is already active (e.g. updating an active plugin) → its settings page exists → link straight to it. $settings = self::get_settings_url( $plugin_file ); if ( $settings ) { $actions['aditya_settings'] = $sep . sprintf( '<a href="%s" target="_parent"><strong>%s</strong></a>', esc_url( $settings ), esc_html__( 'Go to settings', 'aditya-plugin-links' ) ); } } elseif ( $can_set && ! $is_active ) { // Not active yet → don't offer a dead settings link. One click that activates THEN opens settings. $url = wp_nonce_url( admin_url( 'admin-post.php?action=aditya_activate_goto_settings&plugin=' . rawurlencode( $plugin_file ) ), 'aditya_activate_' . $plugin_file ); $actions['aditya_activate_settings'] = $sep . sprintf( '<a href="%s" target="_parent"><strong>%s</strong></a>', esc_url( $url ), esc_html__( 'Activate & open settings', 'aditya-plugin-links' ) ); } $actions['aditya_plugins_page'] = $sep . sprintf( '<a href="%s" target="_parent">%s</a>', esc_url( self_admin_url( 'plugins.php' ) ), esc_html__( 'Go to Plugins page', 'aditya-plugin-links' ) ); return $actions; } /** Activate the plugin, then redirect to its settings (resolving it live now that the plugin is loaded). */ public static function handle_activate_goto_settings() { $file = isset( $_GET['plugin'] ) ? sanitize_text_field( wp_unslash( $_GET['plugin'] ) ) : ''; if ( '' === $file || ! current_user_can( 'activate_plugins' ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'aditya-plugin-links' ) ); } check_admin_referer( 'aditya_activate_' . $file ); if ( ! function_exists( 'activate_plugin' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $res = activate_plugin( $file ); if ( is_wp_error( $res ) ) { wp_safe_redirect( self_admin_url( 'plugins.php?plugin_activation_error=1' ) ); exit; } // Plugin is loaded now → learn its real settings link, then send the user there. $rel = self::settings_from_action_links( $file ); if ( $rel ) { self::cache_set( $file, $rel ); } $url = self::get_settings_url( $file ); wp_safe_redirect( $url ? $url : self_admin_url( 'plugins.php' ) ); exit; } } Aditya_Plugin_Install_Links::init(); endif; ``` ## Install it - Paste it into your (child) theme’s `functions.php`, save it as an mu-plugin (`wp-content/mu-plugins/aditya-plugin-links.php`), or drop it into a code-snippets plugin set to run everywhere. - That’s the whole setup — no options, no config. The next time you install or update a plugin, the links appear on the result screen. - Optional: rename the `Aditya_Plugin_Install_Links` class and the `aditya_` prefixes to your own. It only hooks the `wp-admin` install and Plugins screens, so it does nothing on the front end and adds zero page-load overhead for visitors. ## How well does it really cover “any plugin”? Honest accounting, because “works with everything” is usually a lie: - **Plugins with a settings page — covered.** The destination is whatever the plugin itself declares as its settings link. Tested across 15+ plugins (WooCommerce, Yoast, Rank Math, AIOSEO, Elementor, Contact Form 7, WPForms, Jetpack, LiteSpeed Cache, WP Mail SMTP, ACF, Akismet, Redirection and more) with no mismatches. - **Plugins with no settings page** (Hello Dolly, Classic Widgets) — the correct behaviour is *no* settings link. You just get “Go to Plugins page.” The snippet never invents a page that doesn’t exist. - **Non-English admins — fine.** Matching is on URLs, never on the word “Settings,” so a German or Japanese dashboard resolves identically. - **The known soft spot.** For the rare plugin that exposes several custom action links with *none* of them containing its slug in the URL, the snippet picks the first one — which by long-standing convention is Settings. A plugin that orders its links differently could send you to the wrong one of its *own* pages. That’s the one case the URL-slug heuristic can miss; extend the seed map for that plugin and the learned cache takes over. ## FAQ ### Does it work for ZIP uploads, not just the WordPress.org installer? Yes. `install_plugin_complete_actions` fires for a WordPress.org install, an update, and a manual ZIP upload alike. ZIP uploads were part of the testing. ### Will it ever send me to a 404? For an active plugin it links to the plugin’s own declared URL. For an inactive one it doesn’t offer a settings link at all until *after* activation, so there’s no dead settings link to click. Worst case, you land on the Plugins page. ### Is it safe to run? The activate-and-redirect path is a nonce-checked `admin_post` action that requires `activate_plugins` / `manage_options`. Nothing privileged is exposed to a logged-out or low-role user. ### Will it slow my site down? No. Every hook is on the `wp-admin` install and Plugins screens. The front end never loads it. ### Which plugins are in the seed map, and why? About 22 popular ones — purely so the first click is correct before the learned cache warms up. Once you activate a plugin or visit the Plugins screen, the plugin’s own link is cached and overrides the seed. Full code: [the snippet on GitHub Gist](https://gist.github.com/adityaarsharma/46cec46c9dac76afa395866bb43fc805). Drop it in, install your next plugin, and the “now where are the settings?” step is just gone. If you hit a plugin that resolves to the wrong page, that’s exactly the edge the learned cache is built to swallow — tell me and I’ll fold it into the seed. --- # Build a One-Click EDD Refund Button Inside Fluent Support (Full Code) Source: https://adityaarsharma.com/fluent-support-one-click-refund-button/ **The problem:** a customer opens a ticket asking for a refund. Without this, your agent leaves the ticket, opens the store dashboard, finds the order, refunds it, then cancels the subscription on a separate screen. I wanted one button in the ticket sidebar that does all of it — refund through the original gateway, cancel the subscription, reload. Here is the **complete snippet**, and the five [Fluent Support](https://wordpress.org/plugins/fluent-support/) quirks that stand between you and it. *Last updated: June 2026.* ## How the snippet is wired It's one small class with four moving parts: - **A sidebar widget** — hooks `fluent_support/customer_extra_widgets` to list the customer's [EDD](https://wordpress.org/plugins/easy-digital-downloads/) orders, each with a *Refund and cancel* button. - **A REST endpoint** — `mystore-edd/v1/refund`, locked to `manage_options`, that runs the actual refund and cancel server-side. - **An inline click handler** — a tiny `window.myRefund()` function that POSTs the order ID to the endpoint and reloads the ticket. - **Five workarounds** — because the agent screen is a Vue single-page app on a custom front-end URL, the normal WordPress wiring misbehaves. Each one is called out in the walkthrough below. Prerequisites: Fluent Support and Easy Digital Downloads 3.x active. The subscription-cancel step also needs EDD Recurring. ## The complete snippet `<?php /** * One-click EDD "Refund and cancel" button in the Fluent Support ticket sidebar. * Paste into a code-snippets plugin (run site-wide) or an mu-plugin. * Needs: Fluent Support + Easy Digital Downloads 3.x (EDD Recurring for the cancel). */ if ( ! defined( 'ABSPATH' ) ) { exit; } class MyStore_FS_Refund { const NS = 'mystore-edd/v1'; public function __construct() { add_filter( 'fluent_support/customer_extra_widgets', array( $this, 'widget' ), 999, 2 ); add_action( 'rest_api_init', array( $this, 'routes' ) ); add_action( 'wp_footer', array( $this, 'print_script' ) ); // Fluent Support portal add_action( 'admin_footer', array( $this, 'print_script' ) ); // wp-admin fallback } /** Inject a Refund widget that lists the customer's EDD orders. */ public function widget( $widgets, $customer ) { $email = is_object( $customer ) && isset( $customer->email ) ? $customer->email : ''; if ( ! $email || ! function_exists( 'edd_get_orders' ) ) { return $widgets; } $orders = edd_get_orders( array( 'email' => $email, 'type' => 'sale', // GOTCHA 5: skip type='refund' negative rows 'status' => 'complete', 'number' => 20, ) ); $rows = ''; foreach ( $orders as $order ) { $sub_id = $this->subscription_id( $order->id ); $nonce = wp_create_nonce( 'mystore_refund_' . $order->id ); $label = function_exists( 'edd_currency_filter' ) ? edd_currency_filter( edd_format_amount( $order->total ) ) : $order->total; $rows .= sprintf( '<div>#%1$d - %2$s%3$s' . '<button onclick="window.myRefund(this);return false;" ' . 'style="display:block;margin-top:4px;padding:6px 10px;background:#b00020;color:#fff;border:0;border-radius:4px;cursor:pointer;">Refund and cancel</button></div>', (int) $order->id, esc_html( $label ), $sub_id ? ' (sub #' . (int) $sub_id . ')' : '', (int) $sub_id, esc_attr( $nonce ) ); } $widgets['mystore_refund'] = array( 'header' => 'Refund (EDD)', 'body_html' => $rows ? $rows : 'No refundable orders.', ); return $widgets; } /** Find an active subscription for an order (needs EDD Recurring). */ private function subscription_id( $order_id ) { global $wpdb; $table = $wpdb->prefix . 'edd_subscriptions'; if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) { return 0; // EDD Recurring not installed } return (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE parent_payment_id = %d AND status != 'cancelled' LIMIT 1", $order_id ) ); } /** Register the privileged refund endpoint. */ public function routes() { register_rest_route( self::NS, '/refund', array( 'methods' => 'POST', 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, 'callback' => array( $this, 'do_refund' ), ) ); } /** Refund through the original gateway, then cancel the subscription. */ public function do_refund( $req ) { $order_id = absint( $req->get_param( 'order' ) ); $sub_id = absint( $req->get_param( 'sub' ) ); $nonce = sanitize_text_field( (string) $req->get_param( 'nonce' ) ); if ( ! $order_id || ! wp_verify_nonce( $nonce, 'mystore_refund_' . $order_id ) ) { return new WP_Error( 'bad_nonce', 'Invalid request', array( 'status' => 403 ) ); } if ( function_exists( 'edd_refund_order' ) ) { edd_refund_order( $order_id ); // EDD 3.x routes to Stripe / PayPal automatically } if ( $sub_id && class_exists( 'EDD_Subscription' ) ) { $sub = new EDD_Subscription( $sub_id ); if ( $sub && $sub->id ) { $sub->cancel(); } } return rest_ensure_response( array( 'ok' => true ) ); } /** Print the click handler once, on both the portal and wp-admin. */ public function print_script() { static $done = false; if ( $done || ! is_user_logged_in() ) { return; } // GOTCHA 1 + 2 $done = true; $ep = esc_js( rest_url( self::NS . '/refund' ) ); $rest = esc_js( wp_create_nonce( 'wp_rest' ) ); echo "<script> window.myRefund = function (btn) { if ( ! confirm('Refund and cancel this order? This cannot be undone.') ) return; btn.disabled = true; btn.textContent = 'Processing...'; fetch('{$ep}', { method:'POST', headers:{ 'Content-Type':'application/json', 'X-WP-Nonce':'{$rest}' }, body: JSON.stringify({ order:btn.dataset.order, sub:btn.dataset.sub, nonce:btn.dataset.nonce }) }) .then(function(r){ return r.json(); }) .then(function(){ location.reload(); }) .catch(function(){ btn.disabled = false; btn.textContent = 'Retry'; }); }; new MutationObserver(function(){ document.querySelectorAll('.fs_collapsible_widget').forEach(function(w){ if ( ! w.classList.contains('fs_widget_expanded') ) { var h = w.querySelector('.fs_widget_header'); if (h) h.click(); } }); }).observe(document.body, { childList:true, subtree:true }); </script>"; } } new MyStore_FS_Refund(); ` ## Walkthrough: the five things that fought me The class is short. These five quirks are why it took longer than it should have. ### 1. admin_footer never fires on the portal The Fluent Support agent screen renders on the **front end** (a custom slug like `/helpdesk-backend/`), so `admin_footer` never runs there and your script never prints. `print_script()` is hooked to **both** `wp_footer` and `admin_footer`, with a `static $done` flag so it only outputs once when both fire. ### 2. Don't gate on the page slug My first version checked `strpos( $page, 'fluent-support' )` — but the portal slug isn't that, so nothing loaded. The fix is to drop the page check entirely and gate on `is_user_logged_in()`. Authorization isn't weakened: the REST endpoint still enforces `manage_options` and a per-order nonce. ### 3. Vue eats your click handlers A delegated `document.addEventListener('click', ...)` — even in the capture phase — doesn't fire reliably inside the SPA; Vue's listeners swallow it and re-render the button out from under your handler. The fix that survives re-renders is a native inline `onclick="window.myRefund(this)"` attribute. Vue cannot intercept native HTML `onclick` attributes. ### 4. wp_localize_script silently drops your data Registering a script with a `false` src and attaching data via `wp_localize_script` didn't reliably output on the portal — the localized object simply wasn't there at runtime. So the endpoint URL and nonce are echoed straight into the inline script (the `{$ep}` / `{$rest}` interpolation). One less moving part, zero silent failures. ### 5. EDD's type column hides a phantom refund row List only sales and renewals. EDD 3.x stores each refund as its own row with `type = 'refund'` and a negative total, so an unfiltered query shows ghost −$9.00 "orders" in the sidebar. The `'type' => 'sale'` argument on `edd_get_orders()` keeps those out. ### Bonus: the widget loads collapsed Fluent Support renders sidebar widgets collapsed. The small `MutationObserver` at the end of `print_script()` opens the EDD widget as soon as it appears, so agents don't have to click twice. ## Install it - Confirm Fluent Support and EDD 3.x are active (plus EDD Recurring if you sell subscriptions). - Paste the snippet into a code-snippets plugin set to **run everywhere**, or save it as `wp-content/mu-plugins/fs-refund.php`. - Rename the `mystore-edd` namespace and the `MyStore_FS_Refund` class to your own prefix. - Open a ticket from a customer who has an order. The *Refund (EDD)* widget appears in the sidebar, one button per order. - Click *Refund and cancel*, confirm, and the ticket reloads with the order refunded and the subscription cancelled. ## FAQ ### Does this work with Stripe and PayPal? Yes. On EDD 3.x, `edd_refund_order()` calls the original gateway's refund API for you, so a Stripe order refunds through Stripe and a PayPal order through PayPal — no gateway-specific code in your snippet. ### Will it cancel the subscription too? Yes, when the order has one and EDD Recurring is active. The endpoint loads the linked `EDD_Subscription` and calls `cancel()`, so the customer isn't billed again. ### Is the button safe to expose in the agent portal? The button only carries an order ID, the subscription ID, and a per-order nonce. All privileged work happens in the REST endpoint, which requires `manage_options` and verifies the nonce, so a logged-out or low-privilege user can't trigger a refund. ### Where do I paste it? A code-snippets plugin with site-wide scope, or an mu-plugin. Don't scope it to admin only — the agent portal is a front-end page, so an admin-only snippet never loads there. The class is about 90 lines. The lines were never the hard part — the five Vue-SPA quirks were. Now refunds happen inside the ticket, and nobody touches the store dashboard. --- # How to Apply a WordPress Email Template to Only Specific Emails Source: https://adityaarsharma.com/apply-wordpress-email-template-to-specific-emails/ **Short version:** most "email template" plugins for WordPress wrap *every* email your site sends through one global header and footer — including the newsletters and campaigns you send with [FluentCRM](https://wordpress.org/plugins/fluent-crm/). The result looks broken: your marketing email arrives stuffed inside your transactional receipt design. Here is the approach I actually tested to apply the template to *only* the senders I choose, and leave everything else clean. ## Why one template ends up on every email WordPress sends all of its mail through a single function, `wp_mail()`. Email-design plugins like *[Email Templates Customizer and Designer](https://wordpress.org/plugins/email-templates/)* or *[WP HTML Mail](https://wordpress.org/plugins/wp-html-mail/)* hook into that function and wrap the message body in your header and footer before it leaves. Because they hook `wp_mail()` globally, they wrap **everything** that passes through it. The catch: FluentCRM also sends its campaigns and automations through `wp_mail()`. So your carefully designed newsletter gets double-wrapped inside your transactional template. The same happens to password resets, WooCommerce or EDD receipts, and contact-form notifications. ## Why the plugin's built-in "skip newsletters" option isn't enough Some template plugins try to auto-detect builder emails and skip them. Email Templates, for example, scans the PHP call stack for a FluentCRM or Elementor path and skips wrapping when it finds one. Two reasons that misses real emails: - **It only looks a few frames deep.** The check uses `debug_backtrace()` limited to about 10 frames. When FluentCRM sends from a background cron job, the relevant frame is usually deeper than that, so the detection never sees it. - **Pro add-ons live in a different folder.** FluentCRM's premium features ship in a folder named `fluentcampaign-pro`, which doesn't match a search for "fluent-crm" — so Pro campaigns slip straight through. That's why you can have "skip FluentCRM" logic in place and still watch the template land on a campaign. ## The approach that works: gate by sender, not by guesswork Instead of trusting a backtrace, decide per email using two signals you can actually rely on: - **The `From` address.** Whitelist the exact sender you want branded (for example `billing@yoursite.com`) and disable the template for everyone else. - **A FluentCRM marker.** Tag every FluentCRM email with a custom header so you can exclude it even if a campaign is ever sent from your branded address. Most good template plugins expose a filter to disable themselves per email. Email Templates has `mailtpl_disable_for_email`; WP HTML Mail has `haet_mail_use_template`. Here is the version I tested for Email Templates: `<?php add_filter( 'fluent_crm/email_headers', function ( $headers ) { $headers[] = 'X-Mailer-Origin: FluentCRM'; return $headers; }, 999 ); add_filter( 'mailtpl_disable_for_email', function ( $disabled, $args ) { $headers = isset( $args['headers'] ) ? $args['headers'] : array(); $lines = is_array( $headers ) ? $headers : explode( "\n", str_replace( "\r\n", "\n", (string) $headers ) ); $from = ''; foreach ( $lines as $line ) { if ( stripos( $line, 'x-mailer-origin: fluentcrm' ) !== false ) { return true; // never template FluentCRM } if ( '' === $from && stripos( $line, 'from:' ) === 0 ) { $from = preg_match( '/<([^>]+)>/', $line, $m ) ? trim( $m[1] ) : trim( substr( $line, 5 ) ); } } // Keep the template ONLY for this address; disable everywhere else. return ( strtolower( trim( $from ) ) !== 'billing@yoursite.com' ); }, 10, 2 ); ` Returning `true` skips the template for that email. The logic reads plainly: if it's a FluentCRM email, skip it; otherwise keep the template only when the From address is your one branded sender. ### Using WP HTML Mail instead If you use WP HTML Mail, the equivalent switch is `haet_mail_use_template`, which passes the recipient and subject (but not the From address): `<?php add_filter( 'haet_mail_use_template', function ( $use, $to, $subject ) { // From isn't passed here, so gate by $to / $subject, // or capture From in a wp_mail filter and read it back. return $use; }, 10, 3 ); ` Because From isn't available there, capture it yourself in a `wp_mail` filter and store it, or gate by recipient and subject if that is enough for your case. ### No disable filter? Do it yourself If your template plugin has no per-email hook, skip the plugin and add the header and footer yourself, conditionally, on `wp_mail`: `<?php add_filter( 'wp_mail', function ( $args ) { $headers = isset( $args['headers'] ) ? $args['headers'] : array(); $lines = is_array( $headers ) ? $headers : explode( "\n", str_replace( "\r\n", "\n", (string) $headers ) ); $from = ''; foreach ( $lines as $line ) { if ( '' === $from && stripos( $line, 'from:' ) === 0 ) { $from = preg_match( '/<([^>]+)>/', $line, $m ) ? trim( $m[1] ) : trim( substr( $line, 5 ) ); } } if ( strtolower( trim( $from ) ) !== 'billing@yoursite.com' ) { return $args; // only brand this sender } $head = '<div>Your Brand</div>'; $foot = '<div>Your Brand · yoursite.com</div>'; $args['message'] = $head . $args['message'] . $foot; $args['headers'] = $lines; $args['headers'][] = 'Content-Type: text/html; charset=UTF-8'; return $args; }, 99 ); ` ## Test it before it touches real inboxes Don't validate this by emailing yourself and squinting. Spin up a throwaway WordPress with a fake SMTP catcher and send one test email per sender. I use Docker and [Mailpit](https://github.com/axllent/mailpit): `services: wordpress: image: wordpress:php8.3-apache ports: ["8080:80"] environment: WORDPRESS_DB_HOST: db WORDPRESS_DB_USER: wp WORDPRESS_DB_PASSWORD: wp WORDPRESS_DB_NAME: wp depends_on: [db] db: image: mariadb:11 environment: MARIADB_ROOT_PASSWORD: root MARIADB_DATABASE: wp MARIADB_USER: wp MARIADB_PASSWORD: wp mailpit: image: axllent/mailpit ports: ["8025:8025", "1025:1025"] ` Then add a tiny must-use plugin so WordPress hands its mail to Mailpit instead of the real world: `<?php // wp-content/mu-plugins/mailpit.php add_action( 'phpmailer_init', function ( $m ) { $m->isSMTP(); $m->Host = 'mailpit'; $m->Port = 1025; $m->SMTPAuth = false; } ); ` Send one email from each sender, then open the Mailpit inbox at `http://localhost:8025` and look at the raw HTML. When your branded sender shows the template and everything else comes through clean, you're done — and you proved it without spamming a single real contact. ## Gotchas worth knowing - **Update the plugin first.** Per-email disable filters are relatively new. On an old version the filter simply doesn't exist and your snippet quietly does nothing. - **Read From from the headers, not a fallback.** Don't resolve the sender through `wp_mail_from` as a fallback. If you also force a default From elsewhere, header-less emails get misclassified as your branded sender and wrongly wrapped. - **Header-less emails.** Core WordPress mail such as password resets often has no explicit From. With a strict whitelist those stay un-templated, which is usually what you want. If you need them branded, send them from your branded address at the source. - **Run the snippet everywhere.** FluentCRM sends on cron, not inside wp-admin. If you use a snippets plugin, set the scope to run site-wide, not admin-only, or campaigns never hit your filter. ## FAQ ### Will this stop FluentCRM emails from sending? No. It only stops the design *template* from wrapping them. FluentCRM still sends its own email exactly as you designed it. ### Can I brand more than one sender? Yes. Swap the single-address check for an array of allowed senders and use `in_array()` against the detected From. ### Does this work with any SMTP plugin? Yes. The template decision happens at `wp_mail()`, before the SMTP layer (FluentSMTP, WP Mail SMTP, and the rest) delivers the message, so your routing is unaffected. That's the whole trick: stop letting the template decide for you, and gate it on the one signal you can trust — who the email is from. --- # [SOLVED] How to Fix Contact Form 7 Page Refresh After Submit? Source: https://adityaarsharma.com/how-to-fix-contact-form-7-page-refresh-after-submit/ I understand the importance of having a functional contact form that allows visitors to easily get in touch with me. However, it can be frustrating when the Contact Form 7 plugin on WordPress refreshes the page after submission. This issue can cause visitors to miss error messages or even think that their message was not sent. After encountering this issue myself, I decided to do some research and find a solution. Through my findings, I discovered several ways to fix the Contact Form 7 page refresh after submit problem. By implementing these solutions, website owners can ensure that their visitors have a smooth and hassle-free experience when submitting a contact form. ## Understanding the Page Refresh Problem in Contact Form 7 I will provide an overview of what Contact Form 7 is, what causes page refresh after submit, and why it's an issue. *Do you manage Multiple WordPress websites? We're have compared [8+ WordPress Website Management Tools [Comparison Table]](https://adityaarsharma.com/best-8-wordpress-management-tools/)* ### What is Contact Form 7? Contact Form 7 is a popular plugin for WordPress that allows website owners to create and manage multiple contact forms. It is a free and open-source plugin, which means that anyone can download and use it. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Contact-Form-7-Fix.jpg) ### Why is Page Refresh After Submit a Problem? The page refresh after submitting a form is a problem because it can be confusing for users. If the page refreshes, users may think that their form was not submitted correctly or that there was an error. This can lead to frustration and may cause users to abandon the form altogether. Additionally, if the page refreshes, any errors or missing fields in the form will be displayed. This can be embarrassing for website owners, especially if the form is being used for business purposes. ### What Causes Page Refresh After Submit? One of the main reasons why the Contact Form 7 plugin refreshes the page after submitting a form is due to a JavaScript file not being loaded correctly. This can happen if you are using a theme that has page optimizers like Lite Speed Cache. In this case, the problem is usually with the theme, and you may need to contact the theme developer for assistance. *Learn How to compress your [WordPress site Images in bulk for Free](https://adityaarsharma.com/how-to-compress-wordpress-images-in-bulk/), to make your website lighter & faster using this powerful free tool*. ## 4 Ways to Solve Page Refresh Issue in Contact Form 7 If you are facing the issue of page refresh after submitting a form in Contact Form 7, you are not alone. This issue can be caused by various factors, such as conflicts with other plugins, themes, or server settings. Here are four methods that can help you solve the page refresh issue in Contact Form 7. ### Method 1: Deactivating Other Plugins One of the most common reasons for the page refresh issue in Contact Form 7 is conflicts with other plugins. To check if this is the case, you can deactivate all other plugins except Contact Form 7 and see if the issue persists. If the problem is resolved, then you can reactivate each plugin one by one to identify the conflicting plugin. Once you find the culprit plugin, see if there's an update or try contacting their developer with the issue. ### Method 2: Switching to the Default WordPress Theme and Check Another way to check if the issue is caused by a theme conflict is to switch to the default WordPress theme and see if the problem persists. If the issue is resolved, then you can switch back to your original theme and check if the problem persists. And once again report to the Theme developers ### Method 3: Modifying the Header and Footer Files In some cases, modifying the header and footer files of your theme can solve the page refresh issue in Contact Form 7. You can add the following code to your header.php file just before the `</head>` tag: `<?php wp_head(); ?>` Similarly, you can add the following code to your footer.php file just before the `</body>` tag: `<?php wp_footer(); ?>` *If this is something new for you, then you refer to this blog to learn [How to Add code to the Header & Footer of WordPress safely](https://kinsta.com/knowledgebase/add-code-wordpress-header-footer/)* ### Method 4: Using AJAX to Submit the Form Using AJAX to submit the form can also solve the page refresh issue in Contact Form 7. You can add the following code to your functions.php file: `add_filter( 'wpcf7_ajax_loader', '__return_false' );` ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Adding-Custom-Code-to-Function-php-file-1024x500.jpg) To safely add code to the functions.php file in WordPress, use Child Theme or a code snippet plugin, it is recommended to follow some best practices, if you're new to this, you [can refer to this blog](https://www.wpbeginner.com/beginners-guide/beginners-guide-to-pasting-snippets-from-the-web-into-wordpress/). ## Conclusion In this article, I have provided various solutions to fix the issue of Contact Form 7 page refresh after submit. I have explained the reasons behind this problem and provided step-by-step instructions to resolve it. In conclusion, fixing the Contact Form 7 page refresh after submit issue is crucial to ensure a smooth user experience on your website. By following the solutions provided in this article, you can resolve this problem easily and efficiently. If you're still facing the issue, feel free to comment below or [connect with me](https://adityaarsharma.com/connect/), I will check that for you. --- # How to Save Tweets in Google Sheets with Twitter Search [FREE] + Automate It Source: https://adityaarsharma.com/save-tweets-in-google-sheets-with-twitter-search/ Have you ever found yourself in a situation where you needed to collect and save Tweets on Google Sheet using Twitter Live Search, but didn't know where to start? Certainly, I have. As a marketer, I often need to collect a large number of tweets for my work, but I found that the process can be tedious and time-consuming. That's when I discovered a free and easy solution to tweet archiver in [Google Sheet](https://www.google.com/sheets/about/) using[ App Script function. ](https://www.google.com/script/start/) ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Twitter-Search-in-Google-Sheet-1024x248.jpg) Don't worry, you don't have to code, I will give you an easy copy and paste code, where you just have to replace values. As someone who uses Google Sheets regularly for data analysis, I was excited to learn that it's possible to use Google App script to pull tweets from Twitter's API and insert them directly into a sheet. However, I quickly discovered that the process was not as straightforward as I had hoped, and many of the available solutions came with a price tag or required technical knowledge beyond my comfort level. After much trial and error, I finally stumbled upon a free and easy method that even someone with minimal coding experience can implement Let starts Requirements : - An active Twitter account - Twitter API access - Google Sheets access - Learn how to copy and paste ;) ## How to Create and Apply for a Twitter Developer Account? If you want to use Twitter's API to scrape tweets and add them to a Google Sheet using a Google App script, the first step is to obtain the API key and secret credentials from Twitter's Developer Account. These credentials will give you access to the Twitter API and allow you to make requests for the data you need. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/How-to-Create-Twitter-Developer-Account-1024x495.jpg) #### Step 1: Have a Twitter Account Create a Twitter account if you don't already have one. #### Step 2: Open Twitter Developer Website Go to the [Twitter Developer website](https://developer.twitter.com/en) and sign in with your Twitter account. #### Step 3: Submit Application Details Submit an application detailing your intended use of the Twitter API and the types of data you plan to access. #### Step 4: Agree to Developer Policy and Verify Email Agree to [Twitter's Developer Policy](https://developer.twitter.com/en/developer-terms/agreement-and-policy) and verify your email address. #### Step 5: Answer Additional Questions Answer any additional questions about your intended use of the Twitter API and your technical skills. #### Step 6: Receive API Credentials via Email After providing all necessary information, Twitter will email you with your API key and secret credentials within 48 hours. ## How to Create First App with Twitter Developer Account? The steps to create a Twitter App are: #### Step 1: Sign in to your Twitter Developer Account Go to the [Twitter Developer website](https://developer.twitter.com/en) and sign in to your account. #### Step 2: Go to the Developer Dashboard Once you are signed in, click on the "Developer Dashboard" button in the top right corner of the page. #### Step 3: Create a New App Click on the "Create App" button to create a new app. #### Step 4: Fill in App Details Fill in the details for your app, including the name, description, and website URL. You will also need to select the "App permissions" for the type of data you plan to access. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/image.png) Since we're just searching for twitter with a specific query, then reading access works, but if you're going to create a Twitter or DM someone based on these details it's suggested going with write access as well. Select for Public App, it should work fine. #### Step 5: Generate Client ID and Client Secret Once you have filled in the app details, click on the "Keys and Tokens" tab to generate your Client ID & Secret tokens. You will need to copy these credentials and keep them safe for later use. For our case, we will just require Consumer Key and Consumer Secret Key ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Getting-Twitter-ClientID-and-Secret-ID-1024x495.jpg) And we're done. Now the easiest part, copying :) ## How to Add App Script Code in Google Sheets? So now just create a fresh new Google Sheet, and then click on Extensions → App Script ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Pasting-Google-Script-in-Google-Sheets-Extenstions-1024x495.jpg) And then paste the below code `function getTweets() { var searchOperator = '@adityaarsharma'; // specify your search operator here var numTweets = 10; // specify the number of tweets to retrieve var tweets = searchTweets(searchOperator, numTweets); var sheet = SpreadsheetApp.getActiveSheet(); // clear current data in the sheet sheet.getDataRange().clear(); // write headers to the sheet sheet.getRange(1, 1).setValue('Tweet ID'); sheet.getRange(1, 2).setValue('Tweet Text'); sheet.getRange(1, 3).setValue('Time of Host'); // write tweets to the sheet for (var i = 0; i < tweets.length; i++) { sheet.getRange(i+2, 1).setValue(tweets[i].id_str); sheet.getRange(i+2, 2).setValue(tweets[i].text); sheet.getRange(i+2, 3).setValue(formatDate(tweets[i].created_at)); } } function searchTweets(searchOperator, numTweets) { var tweets = []; var url = 'https://api.twitter.com/1.1/search/tweets.json?q=' + searchOperator + '&result_type=recent&count=' + numTweets; var response = UrlFetchApp.fetch(url, { headers: { 'Authorization': 'Bearer ' + getBearerToken() } }); var data = JSON.parse(response.getContentText()); for (var i = 0; i < data.statuses.length; i++) { var tweet = data.statuses[i]; tweets.push(tweet); } return tweets; } function getBearerToken() { var consumerKey = '*ADD-YOUR-CONSUMER-KEY*'; var consumerSecret = '*ADD-YOUR-CONSUMER-SECRET*'; var encodedConsumerKey = encodeURIComponent(consumerKey); var encodedConsumerSecret = encodeURIComponent(consumerSecret); var combined = encodedConsumerKey + ':' + encodedConsumerSecret; var base64Encoded = Utilities.base64Encode(combined); var url = 'https://api.twitter.com/oauth2/token'; var response = UrlFetchApp.fetch(url, { method: 'post', headers: { 'Authorization': 'Basic ' + base64Encoded, 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, payload: 'grant_type=client_credentials' }); var data = JSON.parse(response.getContentText()); var token = data.access_token; return token; } function formatDate(dateStr) { var date = new Date(Date.parse(dateStr)); return Utilities.formatDate(date, 'GMT', 'yyyy-MM-dd\'T\'HH:mm:ss\'Z\''); } ` To make the Twitter search and Google Sheet integration work, you need to add your Twitter App's API keys in the script. Replace "*ADD-YOUR-CONSUMER-SECRET*" and "*ADD-YOUR-CONSUMER-KEY*" with your app's keys in the script. In the second line of the script, add your [advanced search operator for Twitter](https://zapier.com/blog/twitter-advanced-search-guide/). For example, if you want to search for tweets from a specific account, you can add their username like this: "@adityaarsharma". If you have any questions or need help with your search operator, feel free to connect with me on Twitter. After adding your API keys and search operator, simply run the script from your Google Sheet. The script will create three columns in the sheet: Tweet ID, Tweet Text, and Time of Host. If you need any additional data, don't hesitate to reach out to me. By automating the process with a trigger, you can save time and effort in collecting and analyzing Twitter data. ## How to Schedule Tweets Collection at Regular Interval? Did you find adding tweets to a Google Sheet using Twitter Search and Google App script helpful? Great! Let's take it a step further and automate the process. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Automate-Google-Script-with-Trigger-1024x495.jpg) Rather than manually running the script every time you want to update the sheet with new tweets, you can set up a trigger in Google App script to automatically run the script at set intervals. Here's how: - In the Google App script dashboard, click on "Triggers" and then "Add Trigger". - Choose the function you created earlier, in our case "getTweets". - Set the event source to "Time-driven" and select the interval you want the script to run at. - Click "Save". Now, at the selected time interval, the Google Sheet will automatically be updated with new tweets based on the search operator you provided. This will save you time and effort in data collection and analysis. If you need any help customizing the script or have any questions, feel free to reach out to me via Twitter or email. I'd be happy to assist you. Don't forget to share how you've made use of this workflow and saved time in the comments. And stay tuned for more amazing tips and tricks on using Google Sheets! ## Frequently Asked Questions ### Do I need to know how to code to use Google App script for adding tweets to Google Sheets? While some coding knowledge can be helpful, it's not necessary to know how to code to use Google App script for adding tweets to Google Sheets. With a bit of patience and following the instructions carefully, anyone can implement this solution. ### Can I use this solution to retrieve tweets from multiple Twitter accounts at once? No, this solution is designed to retrieve tweets based on a specific search operator. To retrieve tweets from multiple accounts, you would need to modify the search operator to include the usernames of the accounts you want to retrieve tweets from. ### Can I retrieve tweets from a specific date range using this solution? Yes, you can modify the search operator to include a date range. For example, if you want to retrieve tweets between January 1, 2022 and January 31, 2022, you can add "since:2022-01-01 until:2022-01-31" to your search operator. ### Is there a limit to the number of tweets I can retrieve using this solution? Yes, there is a limit to the number of tweets you can retrieve using the Twitter API. The limit varies based on the type of API endpoint you are using and the number of requests you make within a given time period. However, for most users, the limit is unlikely to be reached. ### Can I customize the script to include additional data fields in my Google Sheet? Yes, you can customize the script to include additional data fields in your Google Sheet. However, this would require modifying the script to retrieve and format the additional data. If you need help with this, feel free to connect with me on Twitter or email. ### Is it safe to use Google App script for retrieving Twitter data? Yes, it's safe to use Google App script for retrieving Twitter data. However, it's important to follow Twitter's API guidelines and not exceed the rate limits or use the data for prohibited purposes. --- # How to Easily Add Custom Link Colors in Elementor [Easiest Way] Source: https://adityaarsharma.com/how-to-easily-add-custom-link-colors-in-elementor/ Is your Elementor website lacking the perfect link color to match your brand or design? Adding custom link colors is a quick and easy way to enhance your website's appearance and make it stand out. With Elementor, you can easily customize your link colors to make them truly your own. In this article, we'll show you the easiest ways to add custom link text colors in Elementor, giving your website the extra edge it deserves. Whether you prefer the default Site settings way or the custom CSS way, you can customize your links to match your brand or design. So, let's dive in and learn how to add custom link colors easily in Elementor – the easiest way! ## Method 1: How to Change Global Elementor Link Color from Site Settings Elementor has a default way to change link colors, which is through the Global Site Settings. Here's how to do it: ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Changing-Elementor-Custom-Links-for-Site-Settings.gif) #### Step 1: Go to Global Site Settings First, go to your WordPress dashboard and navigate to the top left corner Elementor >[ Site Settings. ](https://elementor.com/help/site-settings/)This will take you to the Site Settings panel, where you can set your default colors. #### Step 2: Select Site Colors In the Global Site Settings panel, click on "Site Colors". This will take you to the Site Colors section. #### Step 3: Change Your Link Color Scroll down to the "Links" section and select the color from the color picker or paste your hex code you want for your links. You can choose from the preset colors or enter a custom color code. Once you've selected your color, click on "Save Changes" to save your new default link color. ## Method 2: How to Change Elementor Custom Link Color Using Custom CSS Code? If you want to change the link color for a specific section or widget, you can do so with custom CSS and target it with a class. This is particularly useful if you want to highlight the link colors in a specific section of your website, such as a blog post or a landing page. For example, let's say you want to highlight the link colors in your blog with a specific color. Here's how you can do it: #### Step 1: Add a New Custom Class to Section First, add a new section or widget to your Elementor page. In the section or widget settings, go to the "Advanced" tab. In the "Advanced" tab, add a new class to the "CSS Classes" field. For example, you can add "my-custom-links" as your class. Click on "Update" to save your changes. #### Step 2: Go to the Theme Customizer ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Adding-Custom-Class-to-any-Elementor-Section-1024x716.jpg) Go to your WordPress dashboard and navigate to Appearance > Customize. This will take you to the Customizer. In addition to the customizer, there are other ways to add custom CSS for your link text colors in Elementor. For instance, you can add the code directly in the Elementor Page's Custom CSS to ensure that it only affects the specific page you've added it to. Alternatively, if you want the code to load on all pages of your website, you can add it to the Elementor Header or Footer section. This will ensure that the code is loaded globally on your website. #### Step 3: Add the Custom CSS In the Customizer, click on "Additional CSS". This will take you to the Additional CSS panel. In the Additional CSS panel, add the following code: `.my-custom-links a { color: #ff0000; }` This code will change the color of link for any section with the "my-custom-links" class. You can change the color code as your wish, this way you can also add individual link color. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Adding-Custom-CSS-To-Target-Class-in-Elementor.jpg) #### Step 4: Publish Your Changes Click on "Publish" to save your changes. Your link color will now be customized for the specific section or widget you added the class to. ## Conclusion Now you know how to add custom link colors easily in Elementor. Whether you prefer the default Global Site Settings way or the custom CSS way, you can customize your links to match your brand or design. If you require any help with customization of Elementor, feel free to[ connect with me](https://adityaarsharma.com/connect/) ## Frequently Asked Questions ### Can I change the link color for a specific page or post in Elementor? Yes, you can change the link color for a specific page or post in Elementor by using the custom CSS way. Simply add a new class to the section or widget you want to change the link color for, and add custom CSS in the Additional CSS panel in the Customizer. ### Can I add hover color effects to my custom link colors in Elementor? Yes, you can add hover effects to your custom link colors in Elementor by using custom CSS. For example, you can add the following code to change the link color on hover: *.my-custom-links a:hover { color: #00ff00; }* This code will change the link color to green when the link is hovered over. ### Can I add custom link colors to my Elementor header and footer? Yes, you can add custom link colors to your Elementor header and footer by using the custom CSS way. Simply add a new class to the header or footer section or widget, and add custom CSS in the Additional CSS panel in the Customizer. ### Will changing the link color affect the accessibility of my website? Yes, changing the link color can affect the accessibility of your website. It's important to choose a color that provides enough contrast with the background color, and to ensure that the link is still easily distinguishable from surrounding text. ### Can I use a gradient for my custom link color in Elementor? Yes, you can use a gradient for your custom link color in Elementor by using custom CSS. For example, you can add the following code to use a gradient for your link color: *.my-custom-links a { background: linear-gradient(to right, #ff0000, #00ff00); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }* This code will use a gradient that goes from red to green for your link color. Just add custom class *my-custom-links* to the section you want to add this effect or else you can remove the class and just start with a to effect the whole Elementor website. --- # How to Change Text Content on Page Refresh in WordPress [NO PLUGINS] Source: https://adityaarsharma.com/how-to-change-text-content-on-page-refresh-in-wordpress/ Have you ever wondered how to change the text content on your WordPress website every time a user page re-visits or visits your site? I often find myself looking for ways to make my website more engaging and interactive for my readers. One day, as I was browsing through my website, I started to wonder if there was a way to change the text content on my page each time it was refreshed. In this blog post, I'll share my experience with you and show you how you can implement this on your website. ## The Idea Changing Behind Text Content I thought it would be a great way to keep my readers interested and coming back for more if I could change the text content on my website each time it was refreshed. At first, I didn't know where to start. I did some research and found that there were a few plugins available that could do the job, but I didn't want to rely on plugins for this. I wanted to find a way to do it myself. ## How to Change Text Content on Page Refresh? After some more digging, I found a surprisingly simple solution. By adding an HTML code block with a small amount of JavaScript and CSS, I was able to achieve the desired effect. Here's how I did it: ### Step 1: List the Text Content you want to Show on your Website First, I created a new text and added a list of different phrases that I wanted to appear on my website. I made sure to separate each phrase with a new line. I made a list of text which keeps my visitors motivated. So I complied these - Celebrating diversity, authenticity, and individuality. - Mindful living for a better world - The only way to do great work is to love what you do. - Stay curious, stay inspired, and keep creating. - Seeking the extraordinary in the everyday. I will be adding more of this content soon. ### Step 2: Paste the Code Finally, I added some code to my website that would randomly select one of the phrases from my text each time the page was refreshed. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Add-HTML-Code-in-Elementor-Backed-to-Change-Content-on-Refresh-1024x696.jpg) Here's the exact code I have used in my footer: To customize the text content, simply replace the text within the 'var content =' section with your desired phrases, separated by commas. You can also adjust the font family and size to match your website's branding. If you need any further customization or have any questions, feel free to leave a comment below and I'll be happy to help. `<div><span></span></div> <script> // Define an array of possible content var content = ['Stay curious, stay inspired, and keep creating.', 'Seeking the extraordinary in the everyday.', 'The only way to do great work is to love what you do.', 'Mindful living for a better world', 'Celebrating diversity, authenticity, and individuality.', 'Honoring the past, embracing the present, and shaping the future.']; // Pick a random item from the array var randomIndex = Math.floor(Math.random() * content.length); var randomContent = content[randomIndex]; // Insert the random content into the page document.querySelector('.random-message span').textContent = randomContent; </script> <style> .footer-content { display: flex; align-items: center; justify-content: center; } .footer-content span { font-size: 18px; text-transform: uppercase; letter-spacing: 1px; line-height: 1.5em; font-family: "Josefin Sans"; } @media screen and (max-width: 768px) { .footer-content span { font-size: 16px; text-align: center; } } } </style>` I've added this feature to my website's footer, so anyone who scrolls down to the bottom of the page will be greeted with a cheerful quote to brighten their day :) And here's the quick output, you can also see this live by going to this website footer. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Example-of-Website-Footer-where-content-changes-on-Refresh-1.gif) What's great about this approach is that it's compatible with any WordPress builder. For example, since I use Elementor, I simply added the code using the HTML block. However, if you prefer the Gutenberg block editor, you can also paste the code into the HTML block there. ## Conclusion In conclusion, changing the text content on your WordPress website each time it's refreshed can be a fun and engaging way to keep your readers coming back for more. And the good news is that it can be accomplished without relying on plugins or complicated code. As I've shown, you can create a text file with your desired phrases and use a simple HTML, CSS & JS code snippet to randomly display one of those phrases each time the page is refreshed. This approach works regardless of whether you use Elementor or the Gutenberg block editor. So, why not give it a try and surprise your readers with a new message each time they visit your website? If you have any feedback or suggestions for future blog posts, please let me know in the comments below. And if you need any help with custom code in Elementor or have any questions about the method I shared in this post, don't hesitate to [reach out to me](https://adityaarsharma.com/connect/). I'm always happy to assist fellow WordPress users and help make their websites even better. Thank you for reading, and I hope you found this post helpful! ### What is the benefit of changing the text content on my WordPress website each time it's refreshed? Changing the text content on your website can keep your readers interested and engaged, as they'll be curious to see what new message or quote like in mycase, they'll be greeted with each time they visit your site. This can also make your website feel more personalized and dynamic. ### Do I need to know how to code to implement this method? No, you don't need to be an expert in coding to implement this method. The process involves simply creating a text file and adding a few lines of code to your website using an HTML or JavaScript code block. Where if you require any customization feel free to ping me. ### Can I use this method with any WordPress builder? Yes, this method is compatible with any WordPress builder. Whether you use Elementor, Gutenberg, or another builder, you can simply add the code to your website using an HTML or JavaScript code block. ### Can I customize the text content and font style to match my website's branding? Yes, you can customize the font style by adjusting the CSS in the code. To change the font family, you can modify the 'font-family' property within the CSS, and to change the font size, you can modify the 'font-size' property. For example, if you wanted to change the font family to 'Arial' and increase the font size to 20px, you could modify the CSS as follows: *font-family: Arial, sans-serif; font-size: 20px;* In the style tag --- # How to add Copy Code Button to WordPress Code Blocks – Gutenberg [FREE] Source: https://adityaarsharma.com/how-to-add-copy-code-button-to-wordpress-code-blocks/ As a WordPress user, I often find myself wanting to add a copy code button to my code blocks. It's a small feature, but it can save a lot of time for users who want to quickly copy and paste code snippets from their website. Fortunately, it's easy to add a copy code button to WordPress code blocks using the Gutenberg editor. The Gutenberg editor is a block-based editor that allows users to create rich and dynamic content for their WordPress websites. One of the many blocks available in the Gutenberg editor is the code block, which allows users to add code snippets to their website. And you don't require a plugin! Yes, you read that right! However, the code block doesn't come with a copy code button by default. In this article, I'll show you how to add a copy code button to your WordPress code blocks using the Gutenberg block editor. ## Why Get the Copy Code WordPress Block Button? You may ask, why get it in the first place? Recently, I was working on my blog for Google Script, where I showed [how to save Tweets in Google sheet,](https://adityaarsharma.com/save-tweets-in-google-sheets-with-twitter-search/) where I provided a long script. I wonder who has time to copy and scroll all long for this, I should get a quick copy clipboard button to make it easier. This experience got me thinking about how I could make it easier for my you to use the code snippets I share in my blog posts. And that's when I discovered the copy code button. No more formatting issues, no more manual copying and pasting. Just one click, and the code is copied to their clipboard, ready to be used however they see fit. But it's not just about making things easier. It's also about improving their experience on my site. By providing this functionality, ## How to Get the Copy Code Button in Gutenberg Code Block? Now let's look at the steps from the very beginning. ### Step 1: Create a New Post or Page To add the custom button, you'll need to create a new page or post where you'll place the code block in the WordPress editor. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Adding-Code-Block-in-WordPress.jpg) ### Step 2: Add the code to an HTML block Once you've added this code block, then add a new HTML block to your page or post and paste the code snippet I have shared below: `<script> document.addEventListener('DOMContentLoaded', function() { var codeBlocks = document.querySelectorAll('.wp-block-code'); codeBlocks.forEach(function(block) { var code = block.querySelector('code'); var button = document.createElement('button'); var buttonText = document.createTextNode('Copy Code'); button.appendChild(buttonText); button.style.cssText = 'position: absolute; top: 0; right: 0; margin: 4px; padding: 4px 8px; font-size: 12px; background-color: rgba(200, 200, 200, 0.2); color: #fff; border: none; border-radius: 4px; cursor: pointer; transition: all 0.2s ease-in-out;'; button.addEventListener('mouseenter', function() { button.style.backgroundColor = 'rgba(0, 0, 0, 0.1)'; }); button.addEventListener('mouseleave', function() { button.style.backgroundColor = 'rgba(200, 200, 200, 0.2)'; }); button.addEventListener('click', function() { var range = document.createRange(); range.selectNode(code); window.getSelection().addRange(range); document.execCommand('copy'); window.getSelection().removeAllRanges(); button.innerText = 'Copied!'; button.style.backgroundColor = '#333'; button.style.color = '#fff'; setTimeout(function() { button.innerText = 'Copy Code'; button.style.backgroundColor = 'rgba(200, 200, 200, 0.2)'; button.style.color = '#fff'; }, 3000); }); block.style.cssText = 'position: relative;'; block.insertBefore(button, code); }); var codeTexts = document.querySelectorAll('.wp-block-code pre'); codeTexts.forEach(function(text) { text.style.color = '#fff'; }); }); </script>` As you can see in the code block above, a custom copy code button has been added using the code snippet provided. The button's appearance can be customized by modifying the CSS to change the color, size, or other visual elements to fit your site's design. ### Step 3: Customize the button Text & Color Additionally, the text within the code can be replaced with any desired text to create a custom clipboard button name. For example, you can replace* `Copy Code`* with *`Copy PHP Code` *or* `Get the Code`.* You can customize the appearance of the button to fit your site's design, as well as color code to have your brand colors button. If you require any extra help with customization, feel free to contact or comment below, will get back to you soon. ### Step 4: Test the code in Frontend Test the code to make sure it's working as expected. When a user clicks the Copy Code button, the code should be copied to their clipboard and the button should display a message indicating that the code has been copied. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Live-Preview-of-Copy-Code-Button-on-Code-Block.gif) And that's it! One of the best things about adding this clipboard button to Gutenberg code blocks is that it works with any form of text I add to the code block, even a simple text. If you frequently use code snippets in your blog posts, you might want to consider adding the copy code button to your blog single page so that it's available globally. And if you need any assistance customizing the code, feel free to [contact me](https://adityaarsharma.com/how-to-change-text-content-on-page-refresh-in-wordpress/). I hope this tutorial has been helpful to you. ## Frequently Asked Questions ### What is a custom Copy Code button? A custom copy code button is a button added to a Gutenberg code block that allows users to copy the code snippet with just one click. It makes it easier for readers to use the code in their own projects. ### Why should I add a custom Copy Code button to my Gutenberg code blocks? By adding a custom copy code button, you're making it easy for your readers to use your code snippets in their own projects. It's a small but powerful addition that can make a big difference in the user experience on your site. ### Is it difficult to add a custom copy code button to Gutenberg code blocks? By adding a custom copy code button, you're making it easy for your readers to use your code snippets in their own projects. It's a small but powerful addition that can make a big difference in the user experience on your site. ### Can I customize the appearance of the button? Yes, you can customize the appearance of the button to fit your site's design. For example, you can change the button text or change the colors to match your brand. ### Is the custom copy code button compatible with all WordPress themes? In most cases, yes. However, it's always a good idea to test the code thoroughly to make sure it's compatible with your specific theme and plugins. --- # How to Import YouTube Video Details in Google Sheets Source: https://adityaarsharma.com/how-to-import-youtube-video-details-in-google-sheets/ As a marketer, I always try to keep track of my product's performance on social media platforms. YouTube is one of the most popular social media platforms, and it's essential to keep track of your YouTube channel's stats to analyze how things are performing. However, collecting YouTube video details can be a time-consuming task. That's where Google Sheets comes in handy. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Preview-of-Results-1024x454.jpg) Google Sheets is a powerful tool that allows you to import YouTube video details such as views, likes, comments, and more, directly into your spreadsheet. This feature is incredibly helpful for marketers who want to analyze their YouTube channel's performance without having to manually collect the data. In this article, I will show you how to import YouTube video details in Google Sheets like views, upload date. You will learn how to create an Apps Script project, add YouTube video URLs, and import the data into your Google Sheets. By the end of this article, you will have a clear understanding of how to use Google Sheets to import YouTube video details and analyze your YouTube channel's performance. Above all, if you require any customization on this, feel free to comment below or [connect with me.](https://adityaarsharma.com/connect/) ## Why You Should Collect Data YouTube Data in Sheets? By collecting data in Google Sheets, you can easily analyze the metrics and gain insights into what's working and what's not. This can help you create better content and improve your overall strategy. Here are a few reasons why you should consider collecting YouTube data in Sheets: - You can easily track video views, likes, comments, and other engagement metrics over time. - You can compare the performance of different videos and identify trends and patterns. - You can use the data to inform your content strategy and create more engaging videos. - You can share the data with your team or clients to keep everyone on the same page. With the right tools and strategies, you can take your YouTube channel to the next level and reach a wider audience. ## How to Collect YouTube API Key? Before we can import YouTube video details in Google Sheets, we need to collect our YouTube API key Here's how to do it ### Create a Google Cloud Platform Project Before we can start importing YouTube video details into Google Sheets, we need to create a Google Cloud Platform project. This will allow us to access the YouTube Data API and generate an API key that we can use to make requests to the API. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Creating-new-Google-Cloud-Platform-Project.jpg) To create a new project in Google Cloud Platform, follow these steps: - Go to the [Google Cloud Console](https://console.cloud.google.com/). - Click on the project dropdown menu at the top of the page, then click on the "New Project" button. - Enter a name for your project in the "Project Name" field. - Choose a billing account for your project. If you don't have one set up, you'll need to create one. - Click the "Create" button to create your project. Once your project is created, you'll be taken to the project dashboard. From here, you can manage your project's settings, create credentials, and enable APIs. Now that we have a project set up, we can move on to creating an API key that we can use to access the YouTube Data API. ### Enable YouTube API v3 Key ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Enabling-YouTube-API-1024x771.jpg) In order to import YouTube video details into Google Sheets, we need to enable the YouTube API v3 key. This process involves a few steps, but it is relatively straightforward. Here's what you need to do: - Go to the Google Developers Console and create a new project or select an existing one. - Once you have selected the project, navigate to the "APIs & Services" section on the left-hand side of the screen. - Click on the "Enable APIs and Services" button at the top of the page. - Search for "YouTube Data API v3" and click on it when it appears in the search results. - Click on the "Enable" button to activate the API for your project. - Next, click on the "Create Credentials" button to generate an API key. - Choose "API key" as the credential type. - Copy the API key and save it somewhere safe. You will need it later when connecting to the YouTube Public API Data Key with Google Sheets. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Getting-API-Key-from-YouTube-1024x401.jpg) Enabling the YouTube API v3 key is an essential step in importing YouTube video details into Google Sheets. Once you have completed this step, you will be ready to move on to the next step, which involves connecting to the YouTube Public API Data Key with Google Sheets. *I have also shared how to save Twitter Tweets in Google Sheet for FREE – [Read More here](https://adityaarsharma.com/save-tweets-in-google-sheets-with-twitter-search/)* ## How to Connect YouTube API with Google Sheets? Now that we have the API key, the next step is to connect them to Google Sheets so that the video details can be displayed. Here are the steps to follow: ### Step 1: Create a Google Sheet File Create a new [Google Sheet file](http://sheet.new) and give it a name and save it in your preferred location. ### Step 2: Open App script from Extensions Open the Google Sheet file and click on the menu item *"Extensions"* then click on *"Apps Script"*. This will open the Apps Script editor in a new tab. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Opening-App-Script.jpg) ### Step 3: Copy and Paste the Code Copy and paste the following code into the editor: `function getVideoDetails() { // Replace "Sheet1" with the name of your sheet var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("YouTube"); // Get the range of cells that have the YouTube links var range = sheet.getRange("E2:E"); // column A, starting from row 2 var values = range.getValues(); // Iterate through the YouTube links for (var i = 0; i < values.length; i++) { var youtubeLink = values[i][0]; if (youtubeLink) { // Get the video ID from the YouTube link var videoId = youtubeLink.match(/(?:https?:\/{2})?(?:w{3}\.)?youtu(?:be)?\.(?:com|be)(?:\/watch\?v=|\/)([^\s&]+)/); if (videoId) { // Call the YouTube Data API to get the view count, published date, and video title var url = "https://www.googleapis.com/youtube/v3/videos?id=" + videoId[1] + "&key=[YOUR_API_KEY_HERE]&part=snippet,statistics"; var response = UrlFetchApp.fetch(url); var json = response.getContentText(); var data = JSON.parse(json); var viewCount = data.items[0].statistics.viewCount; var publishedAt = data.items[0].snippet.publishedAt; var videoTitle = data.items[0].snippet.title; // Parse the publishedAt date string to a JavaScript Date object var date = new Date(publishedAt); // Get the day of the month (1-31) from the Date object var day = date.getDate(); // Get the month (0-11) from the Date object and convert it to a string (e.g. "January", "February", etc.) var month = date.toLocaleString("default", {month: "long"}); // Get the full year (e.g. 2020) from the Date object var year = date.getFullYear(); // Concatenate the day, month, and year variables to create the desired date format var formattedDate = day + "-" + month + "-" + year; // Update the cells with the view count, published date, and video title sheet.getRange(i + 2, 6).setValue(viewCount); // column B, starting from row 2 sheet.getRange(i + 2, 4).setValue(formattedDate); // column C, starting from row 2 sheet.getRange(i + 2, 2).setValue(videoTitle); // column D, starting from row 2 } } } } ` Note: Replace "YOUR_API_KEY_HERE" with your API key that you obtained earlier. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Pasting-App-Script-Code-1024x485.jpg) The above code will import YouTube video title, view count, published date in 3 columns. If you require any help with this, feel free to [co](https://adityaarsharma.com/connect/)[nnect with me.](https://adityaarsharma.com/connect/) ### Step 4: Running Script to Test the Output Click on the* "Run"* button to test the script output. If everything is working correctly, the script will fetch the video details and display them in the Google Sheet file. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Output-Stats-1024x577.jpg) That's it! You have successfully connected YouTube API with Google Sheets and can now import video details into your spreadsheet. ## How to Automate YouTube Video Data Collection in Sheets using Triggers If you want to automate the process of collecting YouTube video details in Google Sheets, you can use the inbuilt time-based triggers in Sheets along with App script Triggers page. This allows you to set up triggers that collect data at specific intervals, such as every hour, daily, or weekly. To get started, open your Google Sheet and click on the* "Extensions"* menu. From there, select "App Script" This will open a new window where you can create a new script or edit an existing one. Next, click on the “Triggers” icon in the toolbar. This will open the App script Triggers page, where you can set up a new trigger by clicking on the “Add Trigger” button. When setting up your trigger, you can choose the function you want to run and the time interval at which you want it to run. For example, you could set up a trigger to run the "*getVideoDetails*" function every hour. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Enabling-Time-Based-Trigger-1024x701.jpg) Once you've set up your trigger, you can save your script and close the Script editor window. Your trigger will now run automatically at the specified intervals, collecting YouTube video details and populating them in your Google Sheet. Automating data collection using triggers can save you a lot of time and effort, especially if you need to collect data on a regular basis. With just a few clicks, you can set up a trigger that will automatically collect YouTube video details in your Google Sheet, allowing you to focus on more important tasks. ## Conclusion In this article, I have provided step-by-step instructions on how to connect to the YouTube Data API, pull public data from YouTube to Sheets, create a custom API request, handle filtering and pagination, and import YouTube Analytics to Google Sheets. By following these instructions, you can easily import video details such as views, comments, likes, and dislikes, and use them to create custom reports, charts, and dashboards. You can also use the data to analyze your video performance, identify trends, and make data-driven decisions to improve your content strategy. However, it is important to note that the YouTube Data API has certain limitations and quotas that you need to be aware of. Make sure to read the[ API documentation ](https://developers.google.com/youtube/v3/determine_quota_cost)carefully and follow the best practices to avoid any issues. ## Frequently Asked Questions ### Can I import video details from multiple YouTube channels into one Google Sheet? Yes, you can import video details from multiple YouTube channels into one Google Sheet. It all depends on the video links you paste ### How often can I update the video details in Google Sheets? You can update the video details in Google Sheets as often as you need. However, keep in mind that the YouTube API limits the number of requests you can make per day. If you exceed the limit, you may need to wait until the next day to update the data. ### Can I import the video details of private or unlisted videos? No, you cannot import the video details of private or unlisted videos using the YouTube API. The API only allows you to access the data of public videos. If you need to import the details of private or unlisted videos, you may need to use other methods. ### Can I import video details from a specific playlist? Yes, you can import video details from a specific playlist using the YouTube API. You need to specify the playlist ID in the API request and then use the IMPORTJSON or other functions to extract the data from the JSON response. --- # How to Remove Cells with Specific Text Value in Google Sheets (Using App Scripts) Source: https://adityaarsharma.com/how-to-remove-cells-with-specific-text-value-in-google-sheets/ Have you ever needed to remove cells with specific text value in Google Sheets? It can be a time-consuming task to do it manually, especially if you have a large dataset. Luckily, there is a way to automate this task using App Scripts. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Google-Sheets-Data.jpg) I have found that using App Scripts is a great way to quickly and easily remove cells with specific text value in Google Sheets. App Scripts is a powerful tool that allows you to automate tasks in Google Sheets, including removing cells with specific text value. With just a few lines of code, you can easily remove all cells that contain a specific text value in your spreadsheet. ## Why Remove Cells with Specific Text Value? As I work with Google Sheets, I often find myself needing to remove cells with specific text values. This can be useful in a variety of situations, such as when I need to clean up data or to prepare a sheet for analysis. By removing cells with specific text values, I can ensure that my data is accurate and that my analysis is based on reliable information. Removing cells with specific text values can also help me to save time. Instead of manually deleting each cell, I can use App Scripts to automate the process. This means that I can quickly and easily remove cells with specific text values, without having to spend hours doing it manually. Another reason why I might want to remove cells with specific text values is to improve the readability of my sheet. By removing cells with specific text values, I can make my sheet easier to read and understand. This is particularly important when sharing my sheet with others, as it can help to ensure that everyone is able to quickly and easily understand the information presented. ## Step-by-Step Guide to Removing Cells with Specific Text Value ### Step 1: Open the Script Editor To remove cells with specific text value in Google Sheets, we will use App Scripts. First, open the Google Sheet and click on the Tools menu. From the dropdown menu, select Script editor. This will open the Script Editor in a new tab. ### Step 2: Write the Code to Remove Cells with Specific Text Value Once the Script Editor is open, paste the following code: `function removeDataByText() { var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Sheet1"); // replace "Sheet1" with the name of your sheet var dataRange = sheet.getDataRange(); var data = dataRange.getValues(); var searchText = "DELETE-THIS-TEXT"; // replace with the text you want to remove for (var i = 0; i < data.length; i++) { for (var j = 0; j < data[i].length; j++) { if (data[i][j].toString().indexOf(searchText) !== -1) { sheet.getRange(i + 1, j + 1).clearContent(); } } } } ` Replace "DELETE-THIS-TEXT" with the text value you want to remove. This code will search for the specific text value in all cells of the sheet and remove the entire row if it contains the text value. Example for my case, where I wanted to delete all cells which has word 'SHORT' in it. I was making my YouTube channel's 3 months' roadmap on it. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/App-Script-to-Remove-Cell-Content-1024x487.jpg) ### Step 3: Run the Script to Remove Cells with Specific Text Value After writing the code, save it by clicking the save icon. Then, click on the Run, this will run the script and remove all cells with the specific text value. That's it! You have successfully removed cells with specific text value in Google Sheets using App Scripts. ## Common Errors and Troubleshooting While using the App Scripts to remove cells with specific text value, you may encounter some errors. Here are some common errors and their solutions: ### Error: "TypeError: Cannot read property 'length' of undefined" This error occurs when the script is unable to find any cells with the specified text value. To fix this error, you need to check if the text value exists in the sheet before running the script. You can do this by using the `indexOf()` method to search for the text value in the sheet. If the method returns -1, it means that the text value does not exist in the sheet. ### Error: "TypeError: Cannot call method "toLowerCase" of undefined" This error occurs when the script is unable to convert the text value to lowercase. To fix this error, you need to check if the text value exists in the sheet before running the script. You can do this by using the `indexOf()` method to search for the text value in the sheet. If the method returns -1, it means that the text value does not exist in the sheet. ### Error: "TypeError: Cannot read property 'getValues' of undefined" This error occurs when the script is unable to retrieve the values from the sheet. To fix this error, you need to check if the sheet exists before running the script. You can do this by using the `getSheetByName()` method to get the sheet by name. If the method returns null, it means that the sheet does not exist. By following these solutions, you can easily troubleshoot the common errors that you may encounter while using the App Scripts to remove cells with specific text value in Google Sheets. ## Conclusion In this article, I have shown you how to remove cells with specific text values in Google Sheets using App Scripts. By following the steps outlined in this guide, you can quickly and easily delete rows or columns containing specific text values in your Google Sheet. Using App Scripts is a powerful way to automate tasks in Google Sheets and save time. By creating a script to remove cells with specific text values, you can streamline your workflow and make your work more efficient. Remember to always test your script on a copy of your data before running it on your actual sheet. This will help you avoid accidentally deleting important information. With the knowledge you have gained from this article, you can now confidently remove cells with specific text values in Google Sheets using App Scripts. Whether you are working on a small project or a large data set, this technique will help you clean up your data and make it easier to work with. --- # How to Bulk Send Emails in Gmail using Sheets for FREE [GMass Alternative] Source: https://adityaarsharma.com/how-to-send-mass-email-in-bulk-with-gmail-using-sheets/ Ever wondered how to bulk send emails in Gmail without retyping the same email everytime to each email contact? As a marketer, reaching out to a large number of people can be a daunting task. It becomes even more challenging when you want to personalize each email and make it feel like it was sent to them directly. This was the problem that I faced when I wanted to reach out to a few bloggers to update them about my products. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Bulk-Email-Sending-from-Google-Sheet-using-Gmail-1024x531.jpg) However, I found a solution that helped in send these emails without making it feel like a mass spammy email. This solution involved using the Google Sheet App script for free mass email sending. With this automation, I was able to send personalized emails to each blogger and drive better conversions. This is also a good way where you want to send emails in bulk without adding anyone in bcc or cc so that emails look like it were dedicatedly sent to the person. If you require any help with email marketing, feel free to [connect with me.](https://adityaarsharma.com/connect/) ## Prerequisite for Sending Mass Email in Bulk using Gmail Before sending mass email in bulk with Gmail using the Google Sheet App script, there are a few prerequisites that must be met. These include: - **Gmail account: **A Gmail account is required to use Gmail for sending mass email in bulk. - **Google Sheets:** Google Sheets is a free spreadsheet program that is part of the Google Workspace suite of online tools. You will need to use Google Sheets to create a spreadsheet that contains the email addresses and other information for the recipients of your mass email. It's important to note that there are limits to how many emails you can send per day using Gmail. With a free Gmail account, you can send up to *500 emails per day.* However, if you are using Google Workspace, the limit is* 2,000 emails per day* for each user account. It's also important to remember that sending spam or unsolicited emails is not allowed and can result in your account being suspended or terminated. Always make sure that you have permission from the recipients to send them emails and include an easy way for them to unsubscribe from future emails. By meeting these prerequisites and following best practices, you can use Gmail and Google Sheets to send mass email in bulk for free. ## Setting up the Google Sheet for Sending Email Before sending mass emails through Gmail using Google Sheets and App Script, you need to set up the Google Sheet to include the email addresses and other required details Here's how to set it up: ### Step 1. Opening Script Editor from Sheets Extensions. First, make a copy of this Google Sheet - [Make a Copy of this Google Sheet for Sending Mass Emails](https://docs.google.com/spreadsheets/d/1w8bnEEei0U5fYcOJXfA7ItdyXxnUGnQGJ4vFZrZE04Q/copy) ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Making-the-Copy-of-Google-Sheet-1024x549.jpg) Then, open the Google Sheet and click on the “Extensions” menu. From there, select "App Scripts." This will open the App Script editor in a new window. ### Step 2. Customizing the Google App Script Code (If Required) To customize the Google App Script code for your mail merge automation, you can make a few optional changes to the source code. Here's how : The sample code includes additional parameters, currently commented out, that allow you to control the name of the account the email is sent from, reply-to email addresses, as well as Bcc and Cc email addresses. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Customsing-App-Script.jpg) To activate the parameters you want to add, remove the forward slashes // in front of each one. For example, here's an excerpt from the sendEmails function that activates most of the email parameters: `GmailApp.sendEmail(row[RECIPIENT_COL], msgObj.subject, msgObj.text, { htmlBody: msgObj.html, bcc: 'bcc@example.com', cc: 'cc@example.com', from: 'from.alias@example.com', name: 'name of the sender', replyTo: 'reply@example.com', // noReply: true, // if the email should be sent from a generic no-reply email address (not available to gmail.com users) }); ` Note that the noReply parameter is still commented out because the replyTo parameter is set. If you require any help with this, feel free to [connect with me](https://adityaarsharma.com/connect/), I would love to help you in any way. ## Preparing the Email Content in Gmail for Mass Sending Before sending mass emails, it is important to prepare the content of the email carefully. The content should be concise, clear, and relevant to the recipients. It is also important to ensure that the email is not spam and that the recipients have given their permission to receive the email. To prepare the email content in Gmail, start by creating a new email and adding a subject line that is clear and concise. Then, craft the body of the email with the message you want to convey to your recipients, and then keep this email in draft. Ensure the Email and Google Sheet are created from the same Gmail ID. ### Uploading the Email List and Custom Field(s) Content to Google Sheets The next step is to upload the email list and custom fields to Google Sheets. Simply add the emails in the **Recipient** column, and avoid changing this column title, as emails won't be identified without this. And you can add more columns like Name and add content to them as well. It is important to ensure that the email addresses are accurate and up-to-date, and that none of the emails are invalid. You can verify your email list using tools like [Snov Email Verifier](https://snov.io/email-verifier) to ensure that your emails will be delivered to valid email addresses only. You can make your emails more personalized by using more dynamic fields, such as Name or Reason for Contact by creating a new column in the sheets, which can be added to the email content. This will make each email unique and personalized to the recipient, and make it look less spammy. *I have also created an App Script function where you can store YouTube data in Google Sheet, If you are into YouTube content creation, then this will help you analyze what's working best for you and take correct decisions – [Read More here](https://adityaarsharma.com/how-to-import-youtube-video-details-in-google-sheets/)* ### Adding Dynamic Content in Draft Email Content Now that you have prepared the email content and uploaded the email list to Google Sheets, it's time to add the dynamic fields to the email content. To achieve this, simply add the dynamic field in double curly brackets, such as {{Name}}, where you want the dynamic content to appear. Now come back to your draft email content in the Gmail's to field, and you can add other fields like {{name}} after the greeting line, example like done below. Keeping the 'to' field empty ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Drafting-the-Email-with-dynamic-content-1024x574.jpg) Like this, you can any no. of custom fields in email which changes dynamically, just ensure you have added the column name in brackets like this – ⁣{{Column Head Title}} ## Sending the Mass Email from Google Sheets : The Final Step After setting up the email in draft in Gmail and contact list with necessary details in Google Sheets, it's time to send the mass email. Here's how: - **Click on the Mail Merge Menu to Send Emails** Once the email and other fields have been added to the email with dynamic values, the next step is to send them to all the recipients. After reviewing the email content, click on the Mail Merge button in the top row menu in Google Sheets, near the Help menu, and select 'Send Email' from the dropdown. If you don't see this, refresh the sheet. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Sending-the-Email-from-Mail-Merge-Tab.jpg) - **Add the Subject of Your Draft Email** Here you will be asked to add the subject of your draft email. This should be the same email you just prepared, as it acts as an identifier to connect Google Sheets and Gmail to know which email needs to be sent in bulk. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Type-your-Email-Subject.jpg) - **Click on Send Now** Once the subject is pasted, click on 'Send Email', and the emails will start sending slowly. You will see the current date and time being added to every row with email and custom fields. This confirms that the email has been sent. When you are prompted to authorize the script, click on the "Authorize" button. If the OAuth consent screen displays a warning message saying "This app isn't verified", you can continue by selecting "Advanced" and then clicking on "Go to {Project Name} (unsafe)". This will allow you to proceed with the authorization process. And don't worry, the process does not provide anyone access to your emails, it's basic authentication between your Google Sheet and Gmail so that it can run the script to send the emails. - **Verify if the Email is Sent or Not** You will also see the date and time added in the column 'Email Sent', which confirms your emails are sent successfully ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Email-Sent-Confirmation--1024x463.jpg) To verify that the emails have been sent, check the Sent Tab of your Gmail where you will see the emails being sent to respective emails. > ***QUICK TIP: **To ensure that the dynamic fields and everything are replaced correctly without any errors, it is recommended that you send a test email to your email.* Just add your name and email in the Google Sheets column. By following these simple steps, you can easily send mass emails from Google Sheets using the App script. This is a free and efficient alternative to paid mail merge services. ## Conclusion The Google Sheets App Script is a powerful tool that enables users to send mass emails for free. By following the steps outlined in this article, users can easily create a mail merge alternative that is both efficient and effective. Using the Google Sheets App Script, users can easily create a personalized email that is sent to multiple recipients with just a few clicks. This tool is especially useful for businesses and organizations that need to send mass emails to their customers or members. While there are other mail merge tools available, the Google Sheets App Script is a great alternative for those who want to save money and have more control over their email campaigns. Thanks for reading! let me know in the comments, how helpful this was to run your email campaign efficiently without burning any dollars. --- # How to Extract all YouTube Channel Video Links to Google Sheet for FREE? Source: https://adityaarsharma.com/how-to-extract-all-youtube-channel-video-links-to-google-sheet-for-free/ Using this hack, you can extract all YouTube Videos Links from any YouTube Channel and save them in Google Sheets. If you are a content creator on YouTube, you know how important it is to keep track of your video links. However, manually copying and pasting each link into a Google Sheet can be time-consuming and tedious. Fortunately, with App Scripts, you can extract all YouTube channel video links into a Google Sheet in no time. I have found App Scripts to be an incredibly useful tool. With just a few lines of code, I can extract all the video links from my channel and organize them in a Google Sheet. This not only saves me time but also allows me to easily share the links with my team and track the performance of each video. If you require any customization, you can always[ connect with me.](https://adityaarsharma.com/connect/) ### Requirements To extract all YouTube channel video links into a Google Sheet using App Scripts, we will need the following: - A YouTube API Key - The YouTube Channel ID that you want to extract - A Google Sheet The YouTube API Key is necessary to access the YouTube API and retrieve the video links. You can obtain a YouTube API Key by following the instructions provided by Google. The YouTube Channel ID is the unique identifier for the channel whose videos you want to extract. You can find the Channel ID in the URL of the channel page on YouTube. The Google Sheet will be used to store the extracted video links. You can create a new Google Sheet or use an existing one. Make sure to have the necessary permissions to access and edit the Google Sheet. Once you have the YouTube API Key, YouTube Channel ID, and Google Sheet, you can proceed to the next steps to extract the video links using App Scripts. ## How to Get YouTube Data API Keys? To get your YouTube API Key, you can follow the steps: ### Login to Google Cloud Console To get started with the YouTube Data API, you need to have a Google account. If you don't have one, create a new account. Once you have a Google account, go to the Google Cloud Console and sign in with your credentials. ### Create a New Project and Enable the YouTube Data v3 API After logging in to the Google Cloud Console, create a new project by clicking on the "Select a project" dropdown menu on the top bar and then clicking on the "New Project" button. Give your project a name and click "Create". Once your project is created, select it from the dropdown menu on the top bar. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Creating-new-Google-Cloud-Platform-Project.jpg) Next, you need to enable the YouTube Data v3 API. To do this, click on the "APIs & Services" option in the left sidebar and then click on the "Dashboard" option. Click on the "Enable APIs and Services" button and search for "YouTube Data API v3". Click on the "YouTube Data API v3" result and then click on the "Enable" button. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Enabling-YouTube-API-1024x771.jpg) ### Create and Copy the YouTube API Credentials Now that you have enabled the YouTube Data v3 API, you need to create and copy the API credentials. To do this, click on the "Create Credentials" button and select "API key" from the dropdown menu. Copy the API key that is generated, as you will need it later. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Getting-API-Key-from-YouTube-1024x401.jpg) ### How to Get a YouTube Channel ID? To get the YouTube channel ID, go to the YouTube channel page and look at the URL. The channel ID is the string of characters that comes after "channel/" in the URL. For example, if the URL is "https://www.youtube.com/channel/UCaXk_yWlCuLGYIJSohc0YOw", then the channel ID is "UCaXk_yWlCuLGYIJSohc0YOw". Check the reference for my YouTube Channel from the image below ![](https://adityaarsharma.com/wp-content/uploads/2023/04/How-to-Find-YouTube-Channel-ID-1024x521.jpg) You can also use [this tool to get Channel ID](https://commentpicker.com/youtube-channel-id.php) from a pretty channel links, example I will extract videos link from the famous [Brian Dean's channel](https://www.youtube.com/@BrianDean/), but when we open the channel we find – https://www.youtube.com/@BrianDean/ in the URL bar. So, just paste the URL on the above website and it will show you the channel ID. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Copying-Channel-ID-if-you-find-it-1024x611.jpg) Therefore, by following these steps, you can obtain your YouTube Data API keys and channel ID. With this information, you can use the API Connector and API Requests to extract all YouTube channel video links into Google Sheet using App Scripts. You can also filter the video links by title and parameters as needed. ## How to Connect YouTube Data API with Google Sheets? Connecting YouTube Data API with Google Sheets is a simple process that requires a few steps. Follow the steps below to connect the YouTube Data API with Google Sheets: ### Creating the App Script Code in Sheets The first step is to create an App Script code in Google Sheets. Here's how: - Open a Google Sheet and click on the "Extensions" menu. - Select "App Scripts" from the drop-down menu. - A new tab will open with a blank script. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/chrome_j0QQLq7SgL.jpg) After opening it, create a script and paste this code below `function listVideos() { // Replace CHANNEL_ID with the ID of the channel var channelId = "CHANNEL_ID"; // Replace API_KEY with your API key var apiKey = "API_KEY"; // Get the spreadsheet and the sheet var spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); var sheet = spreadsheet.getSheetByName("YouTube"); // Set the API endpoint and parameters var endpoint = "https://www.googleapis.com/youtube/v3/search"; var params = { part: "snippet", type: "video", channelId: channelId, key: apiKey, maxResults: 300, videoDefinition: "high", videoEmbeddable: "true", videoSyndicated: "true" }; // Initialize the row index var rowIndex = 2; // <-- Start writing the results from the 2nd row // Loop through all the pages of results while (true) { // Make the API request var response = UrlFetchApp.fetch(endpoint + "?" + buildQueryString(params)); var data = JSON.parse(response.getContentText()); // Exit the loop if there are no more results if (!data.items || data.items.length == 0) { break; } // Loop through the results for (var i = 0; i < data.items.length; i++) { // Get the video title and URL var title = data.items[i].snippet.title; var url = "https://www.youtube.com/watch?v=" + data.items[i].id.videoId; // Write the URL to the sheet sheet.getRange(rowIndex, 8).setValue(url); // <-- Write the URL to the cell rowIndex++; } // Set the next page token params.pageToken = data.nextPageToken; } } // Helper function to build a query string from an object function buildQueryString(params) { var query = []; for (var key in params) { query.push(encodeURIComponent(key) + "=" + encodeURIComponent(params[key])); } return query.join("&"); } ` And you're done with most of the work now. ### Adding the Global API & Channel ID in Code After pasting the App Script code, the next step is to add the global API and channel ID you have collected earlier. Here's how: Replace *CHANNEL_ID* with the channel you want to collect links of, and *API_KEY *with the YouTube Global Data API you have generated from your Google Cloud console. ### Make a Test Run of App Script Once you have added the global API and channel ID, you can make a test run of the App Script. Here's how: - Click on the "Run" menu and select "listVideos". - Wait for the script to run. You should see a list of video links in your current Sheet Page ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Extracting-YouTube-Links-from-Any-YouTube-Channel-1024x748.jpg) *If you want to extract other details like Video views, Video Title etc., then I would suggest you to [my this blog](https://adityaarsharma.com/how-to-import-youtube-video-details-in-google-sheets/) where I talked about this using a different script*. Please have no hesitation to [connect with me](https://adityaarsharma.com/connect/) if you would like to collect any other details from YouTube API, I open for custom works. ### BONUS: How to Automate this using App Script Triggers If you want to automate the process of extracting video links from a YouTube channel to a Google Sheet, you can use App Script triggers. Here's how: - In the App Script dashboard, click on trigger menu i.e. 3rd icon from top. - Click on the "Add trigger" button in the bottom right corner. - Set the trigger settings to the following: Choose which function to run: listVideos - Choose which deployment should run: Head - Select event source: Time-driven - Select type of time based trigger: Hour timer - Select hour interval: Every hour - Click on the "Save" button. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Enabling-Time-Based-Trigger-1024x701.jpg) With the trigger set up, the script will run automatically every hour and update the video links in the Google Sheet. ## Conclusion After going through the steps outlined in this article, I was able to successfully extract all YouTube channel video links into a Google Sheet using App Scripts. This process can be extremely useful for content creators, marketers, and anyone who wants to keep track of their favorite YouTube channels. By using the YouTube Data API and Google Sheets, we were able to automate the process of extracting video links, titles, and other important information. This saves a lot of time and effort compared to manually copying and pasting links into a spreadsheet. One thing to note is that the process may take longer for channels with a large number of videos. However, the script can be modified to run in batches to avoid exceeding the API quota limit. Additionally, the script can be customized to extract other types of data such as video descriptions, tags, and comments. Overall, I found this to be a very helpful and efficient way to extract YouTube channel video links into a Google Sheet. With a little bit of coding knowledge and some patience, anyone can use this method to automate their YouTube data collection process. --- # How to Prevent WooCommerce Add to Cart Dynamic URLs from Crawling? Source: https://adityaarsharma.com/how-to-prevent-woocommerce-add-to-cart-dynamic-urls-from-crawling/ If you're running an online store with WooCommerce, you may have noticed that add to cart buttons generate dynamic URLs. These URLs can be crawled by search engine crawlers and bots, which can lead to issues with indexing and SEO. In this article, I will provide you with some tips on how to prevent WooCommerce add to cart dynamic URLs from crawling and indexing. One way to prevent bots from crawling add to cart URLs is by disallowing them in your robots.txt file. This will prevent search engine crawlers like Googlebot and Bingbot from indexing these pages. Let's see how ## Understanding Dynamic URLs in WordPress As an eCommerce platform, WooCommerce uses dynamic URLs to create unique product pages for each item in a store's inventory. Dynamic URLs contain variables and parameters that generate content based on user input or database information. They are often used in conjunction with add-to-cart buttons to enable customers to purchase products directly from a product page. ### What are Dynamic URLs? Dynamic URLs are URLs that contain variables and parameters that generate content based on user input or database information. They are used to create unique product pages for each item in a store's inventory. For example, a dynamic URL might include a product ID, a category, or a price range. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Dynamic-URLS-testing-using-Site-Auditor.jpg) When a customer clicks on a product page, the dynamic URL generates the appropriate content based on the variables and parameters in the URL. ### Why are Dynamic URLs Used in WooCommerce? Dynamic URLs are used in [WooCommerce](https://woocommerce.com/) to create unique product pages for each item in a store's inventory. This allows customers to view and purchase products directly from a product page, rather than having to navigate through multiple pages to find what they are looking for. Additionally, dynamic URLs allow store owners to track customer behavior and generate analytics data that can be used to improve the shopping experience and increase sales. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/WooCommerce-Testing-1024x495.jpg) While dynamic URLs can have a negative impact on SEO, there are ways to mitigate these issues. For example, store owners can use canonical tags to indicate the preferred URL for a piece of content, or they can use URL parameters to create cleaner, more readable URLs. Additionally, store owners can use robots.txt files to prevent search engines from crawling certain pages or directories. ### How to Identify If Google has crawled your Website's Dynamic URLs? To identify if Google has crawled your website's dynamic URLs using the [Google Search Console](https://search.google.com/search-console/welcome) : - Log in to your Google Search Console account and navigate to the "Index" tab. - In the "Index" tab, you'll see a list of all the pages on your website that Google has crawled and indexed. Look for your dynamic URLs in this list. - If your dynamic URLs are included in the list, then Google has crawled them. You can click on each URL to see more information about its indexing status and any issues that Google may have encountered while crawling it. You can also use Technical SEO tools like [Screaming Frog](https://www.screamingfrog.co.uk/seo-spider/) or [Website Auditor.](https://www.link-assistant.com/website-auditor/) ## Why Prevent Dynamic URLs from Crawling? As a WooCommerce website owner, it is essential to understand the impact of dynamic URLs on your website's SEO. Dynamic URLs are URLs that contain parameters and change dynamically based on user input or session information. Crawling dynamic URLs can negatively affect your website's SEO and lead to duplicate content issues. ### What Happens When Dynamic URLs are Crawled? Crawling dynamic URLs can cause several issues for your WooCommerce website. Here are a few examples: - Search engines may index multiple versions of the same page, leading to duplicate content issues. - Crawling dynamic URLs can cause search engines to ignore or overlook some of your website's pages. - Crawling dynamic URLs can also lead to crawl budget wastage, where search engines spend time crawling unnecessary pages instead of important pages. ### How Does Crawling Dynamic URLs Affect SEO? Crawling dynamic URLs can significantly impact your website's SEO. Here are a few ways: - Dynamic URLs can make it challenging for search engines to understand the structure of your website and the hierarchy of your pages. - Dynamic URLs can lead to duplicate content issues, which can hurt your website's ranking and visibility in search engine results pages. - Crawling dynamic URLs can also lead to crawl budget wastage, where search engines spend time crawling unnecessary pages instead of important pages, which can affect your website's overall ranking and visibility. Therefore, it is crucial to prevent search engines from crawling dynamic URLs to ensure that your website's SEO is not negatively impacted. ## Preventing Dynamic URLs from Crawling To prevent Search Engine crawlers to seeing the dynamic add to cart and other WooCommerce links we will be using robot.txt file ### Using the Robots.txt File One of the simplest ways to prevent search engines from crawling dynamic URLs is by using the robots.txt file. This file is a standard used by search engines that tells them which pages to crawl and which ones to avoid. To prevent dynamic URLs from being crawled, you can use the "Disallow" directive in your robots.txt file. For example, if you want to prevent search engines from crawling all URLs that contain the word "add-to-cart", you can add the following line to your robots.txt file: `# Preventing WooCommerce Dynamic URLS User-agent: * Disallow: *?add-to-cart=* Disallow: *?remove_item=* Disallow: *?removed_item=* Disallow: *?undo_item=*` This will tell search engines to avoid any URLs that contain the *"add-to-cart, remove_cart, removed_item and undo_itme"* strings. ### Creating Robots.txt in WordPress Root Directory If you are using WordPress, you can easily create a robots.txt file in the root directory of your website. To do this, simply create a new file called "robots.txt" and add the "Disallow" directive as described above. You can also use plugins like [Yoast SEO](https://wordpress.org/plugins/wordpress-seo/) to generate a robots.txt file automatically. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Create-Robot.Txt-File-in-WordPress-root-path-1024x495.jpg) ### Testing Robot TXT from Google Search Console After creating your robots.txt file, it is important to test it to make sure it is working correctly. You can use this Robot TXT checker tool to test your robots.txt file content and see if you find any errors. [Open Robot.txt Tester](https://technicalseo.com/tools/robots-txt/) To do this, go to the "robots.txt Tester" tool in the Google Search Console and enter the URL of your robots.txt file. The tool will then show you any errors or warnings and give you suggestions on how to fix them. ## Conclusion Overall, preventing dynamic URLs from being crawled is an important aspect of SEO for any WooCommerce website. By using the methods outlined in this article, you can ensure that your website is crawled efficiently and without any duplicate content issues. --- # How to Protect Emails Scraping from Spam Bots in WordPress? (Using Cloudflare) Source: https://adityaarsharma.com/how-to-protect-emails-scraping-from-spam-bots-in-wordpress/ If you have a WordPress website, you might be concerned about how to protect your email address from being harvested by bots. Spam bots can crawl your website and collect email addresses to send spam emails. This can be a serious problem for your website's security and reputation. One way to protect your email address from bots is by using [Cloudflare Scrape Shield](https://developers.cloudflare.com/support/more-dashboard-apps/cloudflare-scrape-shield/what-is-email-address-obfuscation/). This is a free toll in the Cloudflare dashboard that can help you protect your website's content. Scrape Shield can scramble email addresses on your website to make it harder for bots to collect them. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Scrape-Shield-New-Update-Dashboard-Cloudflare-Settings.jpg) To use Cloudflare Scrape Shield, you need to sign up for a Cloudflare account and add your website to the Cloudflare dashboard. Once your website is added, you can enable Scrape Shield and configure the settings to protect your email address. This can be a simple and effective way to protect your website from spam bots without using any plugins. ## Understanding Email Harvesting in WordPress ### What is Email Harvesting? Email harvesting is a technique used by spammers to collect email addresses from websites. They use bots that scan web pages and extract email addresses to create mailing lists. These lists are then used to send spam emails or sell to other spammers. Email harvesting can be a serious problem for WordPress users, as it can lead to an increase in spam emails and potentially harm their website's reputation. ### Why is Email Harvesting a Concern for WordPress Users? Email harvesting can be a concern for WordPress users for several reasons. Firstly, it can lead to an increase in spam emails, which can be annoying for website visitors and potentially harm the website's reputation. Secondly, it can also lead to a decrease in website traffic, as visitors may be deterred from visiting a website that is known for sending spam emails. Finally, email harvesting can also lead to a decrease in website security, as spammers may use email addresses to gain access to a website's backend. Fortunately, there are several ways to protect your website from email harvesting. One of the most effective ways is to use Cloudflare Scrape Shield. *If you add custom codes like me on your website, then you might love this. I share an easy code to add Copy Code button to the default WordPress Code block.* - *[Know How to do it.](https://adityaarsharma.com/how-to-add-copy-code-button-to-wordpress-code-blocks/)* ## Cloudflare Scrape Shield: An Overview As a website owner, protecting your email addresses from bots is essential to avoid spam and other malicious activities. One way to do this is by using Cloudflare Scrape Shield. In this section, I will provide an overview of what Cloudflare Scrape Shield is and how it works. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Cloudflare-Page-Shield-Page-1024x745.jpg) ### What is Cloudflare Scrape Shield? Cloudflare Scrape Shield is a feature that protects your website from email harvesting bots. It is a part of Cloudflare's suite of security tools that help website owners protect their websites from various online threats. Scrape Shield includes several features that help prevent email harvesting, such as email obfuscation, hotlink protection, and server-side excludes. These features work together to make it difficult for bots to scrape your website for email addresses and other sensitive information. ### How Does Cloudflare Scrape Shield Work? Cloudflare Scrape Shield works by implementing several measures that make it difficult for bots to scrape your website for email addresses. Here are some of the key features: - **[Email Obfuscation:](https://developers.cloudflare.com/support/more-dashboard-apps/cloudflare-scrape-shield/)** Scrambles email addresses on your website to prevent bots from easily recognizing and collecting them. - **[Hotlink Protection](https://developers.cloudflare.com/support/more-dashboard-apps/cloudflare-scrape-shield/understanding-cloudflare-hotlink-protection/):** Prevents bots from using your website's images and other media files to create spammy pages or other malicious activities. - **[Server-Side Excludes](https://developers.cloudflare.com/support/more-dashboard-apps/cloudflare-scrape-shield/what-does-server-side-excludes-sse-do/):** Allows you to exclude certain parts of your website from being crawled by bots, further reducing the chances of email harvesting. By implementing these measures, Cloudflare Scrape Shield helps website owners protect their email addresses and other sensitive information from bots and other malicious activities. ## Implementing Cloudflare Scrape Shield in WordPress Protecting your website from bots that harvest email addresses is important to prevent spam and other malicious activities. Cloudflare Scrape Shield is a free app that can help you protect your website from email harvesting bots. Here are the steps to implement Cloudflare Scrape Shield in WordPress: ### Step 1: Create a Cloudflare Account To use Cloudflare Scrape Shield, you need to create a Cloudflare account. Go to[ Cloudflare Website](https://www.cloudflare.com/) and click on the "Sign Up" button. Fill in the required information and click on the "Create Account" button. If you already have your Cloudflare website then skip this ### Step 2: Add Your Website to Cloudflare After creating your account, you need to add your website to Cloudflare. Click on the "Add a Site" button and enter your website's URL. Cloudflare will scan your website and provide you with a list of DNS records. Verify that the DNS records are correct and click on the "Continue" button. ### Step 3: Activate Cloudflare Email Address Obfuscation from Scrape Shield Once your website is added to Cloudflare, you can activate the Email Address Obfuscation feature from Scrape Shield. Go to the Scrape Shield app in the Cloudflare dashboard and toggle on the "Email Address Obfuscation" feature. This feature will scramble email addresses on your website to prevent bots from harvesting them. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Turn-on-Email-Protection-in-Cloudflare-1024x536.jpg) By following these three simple steps, you can implement Cloudflare Scrape Shield in WordPress and protect your website from email harvesting bots. Remember to keep your website secure by regularly updating your WordPress installation and plugins. To test if this works or not, you can check this[ Free Tool.](https://wpemailencoder.com/email-protection-checker/) ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Test-Email-Protection-Tool-1024x368.jpg) So as you can see, even though my website has a clear email in the footer, no bots can crawl and spam my email. ## Additional Tips for Protecting Your Emails from Harvesting ### 1) Use a Contact Form Plugin I recommend using a contact form plugin rather than displaying your email address on your website. This will prevent bots from easily scraping your email address from your website's HTML. A contact form plugin will allow visitors to send you an email without revealing your email address to them. You can go for plugins like[ Contact Form 7](https://wordpress.org/plugins/contact-form-7/) or [Fluent Forms.](https://wordpress.org/plugins/fluentform/) ### 2) Avoid Displaying Emails on Your Website Do not display your email address in plain text on your website. Instead, consider using an image or JavaScript to display your email address. This will make it more difficult for bots to scrape your email address from your website's HTML. ## Conclusion In conclusion, protecting email addresses from bots is crucial to maintaining the integrity and security of your website. While there are several plugins available, using Cloudflare Scrape Shield is a great alternative for those who prefer not to use plugins. With Scrape Shield, you can easily protect your email addresses from being harvested by bots without sacrificing user experience or website functionality. --- # Reasons Why Bitwarden is My Go-To Password Manager as a Marketer Source: https://adityaarsharma.com/reasons-why-bitwarden-is-my-go-to-password-manager/ As a marketer, I understand the importance of keeping sensitive information secure. One of the most crucial aspects of online security is password management. With so many passwords to remember, it can be challenging to keep track of them all. That's where password managers come in handy. After trying several different password managers, I ultimately decided to use Bitwarden. In this article, I will share 14 reasons why I use Bitwarden as my password manager of choice. ## The Importance of Password Management for Marketers With the increasing number of data breaches and security flaws, it's more important than ever to use strong and unique passwords for each of my accounts. ### The Risks of Weak Passwords Using weak passwords is a common mistake that many people make. It's easy to remember simple passwords, but they are also easy for hackers to guess. If you use the same password for multiple accounts, a data breach on one site can compromise all of your accounts. This can lead to [identity theft, ](https://www.investopedia.com/terms/i/identitytheft.asp)financial loss, and other serious consequences. ### The Benefits of Using a Password Manager A password manager like [Bitwarden](https://bitwarden.com/) can help you generate and store strong passwords for each of your accounts. Where you only need to remember one master password, which unlocks access to all of your other passwords. This makes it easy to use unique and complex passwords for each of your accounts, without having to remember them all. ## Why Bitwarden is the Best Password Manager for Marketers You likely deal with a lot of sensitive information on a daily basis, including login credentials for various accounts and SaaS used for marketing, such as SEMrush, multiple Google accounts, and more. It is important to ensure that this information is properly protected to prevent unauthorized access and potential security breaches. ### Reason 1: Enhanced Security for my Password without Remembering Them Bitwarden offers end-to-end encryption, which means my data is encrypted and decrypted locally on my device. This ensures that my sensitive information is protected from hackers and other third-party entities. Additionally, I don't have to remember all my passwords, which can be a daunting task. Bitwarden securely stores them for me, and I can access them with just one master password. *Are you worried of bot scraping your website to spam your emails, then [check this blog](https://adityaarsharma.com/how-to-protect-emails-scraping-from-spam-bots-in-wordpress/) where I shared how to can prevent email scraping for free.* ### Reason 2: Cross-Platform Compatibility in the Free Version as well ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Bitwarden-Password-Manager-1024x648.jpg) Bitwarden is available on all major web browsers, including Chrome, Firefox, Edge, and Safari. It also has apps for iOS and Android devices. What's more, I can use the free version of Bitwarden on multiple devices without any limitations. Unlike LastPass 😉 *Wondering using Browser Password Manager is same as using Bitwarden, well there's a difference - [**Read here in Detail**](https://adityaarsharma.com/why-choose-bitwarden-over-chromes-browser-password-manager/)*s ### Reason 3: Auto-Fill Passwords (With Keyboard Shortcut) Bitwarden's autofill feature makes it easy for me to log in to my accounts quickly without typing them everytime. With just a keyboard shortcut, I can fill in my login credentials without having to type them out manually. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Using-Keyboard-Shortcut-to-Fill-Details-in-Bitwarden.gif) For example, when I visit the SEMrush domain and hit Ctrl + Shift + L, and the email and password are added to the respective fields. Important point, I do not recommend automatic autofill on the website load, as it can lead to phishing. You can read more about [this from here.](https://marektoth.com/blog/password-managers-autofill/) ### Reason 4: Inbuilt Two-Factor Authentication (No need of 3rd Party Apps) It comes with an inbuilt two-factor authentication feature that adds an extra layer of security to my account. I don't have to rely on third-party apps to get two-factor authentication code. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Inbuilt-2-Factor-Authentication.jpg) While apps like Google Authenticator are popular choices for 2FA, they have their drawbacks. For example, if you lose your phone, you lose access to all your 2FA details. Additionally, you may have to depend on your phone to check your login devices, which can be inconvenient. You can [read more on this](https://www.zdnet.com/article/using-google-authenticator-heres-why-you-should-get-rid-of-it/), on why you should get rid of Google Authenticator to avoid any nightmare situation. And I just visited the Review page of Google Authenticator and on top you see such scary incidents. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Google-Authenticator-negative-Reviews.jpg) So to avoid such things from happening to me, I stick with inbuilt using Bitwarden. Best part it automatically copies the 2FA code once I log in with email & pass, so I just have to hit paste on the 2FA field, amazing, isn't it? ### Reason 5: Highly Secure and Open-Source with 3rd Party Regular Audits One of the key benefits of Bitwarden is that it is open-source, which means that its code is available for anyone to inspect. This level of transparency can help to build trust with users, as they can be confident that the service is not hiding anything. In addition to being open-source, Bitwarden is also highly secure. The company uses robust security standards, including end-to-end encryption, to protect critical data. Furthermore, it undergoes regular third-party audits to ensure that its security measures are up-to-date and effective. This commitment to security is a key reason why they are trusted by businesses and individuals alike. Many popular Password Managers like LastPass, 1Password, Dashlane are closed source, and therefore I avoid using them. ### Reason 6: Zero-Knowledge Security Model – Data is Encrypted and Decrypted locally When it comes to online security, one of the most important factors to consider is how your data is being protected. One approach that has gained popularity in recent years is the zero-knowledge security model. This approach ensures that your data is encrypted and decrypted locally on your device, rather than being stored on a central server where it could potentially be accessed by hackers or other third-party entities. By using this model, you can be confident that your sensitive information is being protected at all times. This is because your data is never stored in an unencrypted form on a server, which means that even if a hacker were to gain access to the server, they would not be able to read your data without your encryption key. ### Reason 7: Secure Password Generator This tool creates strong, unique passwords for you, which can help to protect your accounts from brute-force attacks. Brute-force attacks are a common technique used by hackers to crack passwords by trying every possible combination until they find the correct one. By using a strong, unique password that is generated by Bitwarden, you can make it much more difficult for hackers to gain access to your accounts. Best part, you don't have to remember it ![](https://adityaarsharma.com/wp-content/uploads/2023/04/image-1-1024x1024.png) If you're someone who finds it difficult to remember complex passwords, you may be wondering why bother with them at all? The answer is simple: the more complex your password, the tougher it is for bots or anyone to guess. ### Reason 8: Share Passwords with Trusted Family, Friends & Teammates One of the great features of Bitwarden is its ability to securely share passwords with trusted family, friends, and team members. This can be especially useful when working on collaborative projects or sharing accounts with family members. With Bitwarden's sharing feature, you can easily and securely share passwords with others without having to worry about compromising your security. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Password-Sharing.jpg) You can also choose to give the person read-only access or full access, depending on your needs. This level of control ensures that you can share passwords with others while still maintaining control over who has access to your sensitive information. Another benefit of Bitwarden's sharing feature is that it allows you to revoke access to passwords at any time. This can be useful if you no longer want someone to have access to a particular password, or if you need to remove access for security reasons. ### Reason 9: Emergency Access – Allows you to grant trusted contacts access to your vault Bitwarden's emergency access feature is a powerful tool that can help you to prepare for unexpected situations. With this feature, you can grant trusted contacts access to your vault in case of an emergency. This ensures that your accounts are still accessible even if you're unable to access them yourself. In my case, I have added this to my brother, who can take over if for any reason i am unable to access my account. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Emergency-Access-in-Bitwarden-1024x545.jpg) To use the emergency access feature, you can designate trusted contacts who will be able to access your vault in case of an emergency. You can choose to give them full access or view-only access, depending on your needs. Once you've designated your emergency contacts, they will receive an invitation to accept emergency access to your account. One of the benefits of this feature is that it allows you to set a waiting period before emergency access is granted. This waiting period can range from one hour to 30 days, giving you time to cancel the request if it was made in error. This level of control ensures that you can grant emergency access to your vault while still maintaining control over who has access to your sensitive information. ### Reason 10: Bitwarden Send is a secure way to share information directly with another person My favorite and most helpful to me, Bitwarden's Send feature, is a convenient and secure way to share information with another person. This feature is especially useful when sharing sensitive information, such as credit card details or login credentials, and files as well. Example when i have to share any personal details like my Personal ID card, or any SSH file for client servers i use Bitwarden Send rather than sending direct plain text/file on WhatsApp or email. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Bitwarden-Send.jpg) Another benefit of the Send feature is that it allows you to set user-configured parameters to further protect the information you're sharing. For example, you can set a deletion date for the Send object, ensuring that the information is automatically deleted after a certain period of time. This level of control ensures that your sensitive information is only accessible for as long as it needs to be. ### Reason 11: Bitwarden can store credit card information and other key Personal IDs Bitwarden is a versatile password manager that can store not only your usernames and passwords, but also other important personal information. This includes credit card information, passport numbers, and driver's license details, among others. By storing this information here you can easily access it when needed, without having to remember each detail. Like I have stored 2 of my Credit card details here ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Bitwarden-Credit-Card.jpg) This way I don't have to remember my credit card long details, I just enter my CVV and the make the payments. And since no payment can be remitted without OTP verification on my number, this makes stuff easier and secure for me. ### Reason 12: Vibrant Helpful Community (Specially Reddit for Me) Bitwarden has a vibrant and helpful community on Reddit that can be a valuable resource for users. By joining the [r/Bitwarden ](https://www.reddit.com/r/Bitwarden/)subreddit, you can connect with other users, get answers to your questions, and learn more about the product. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Bitwarden-Reddit-Community-1024x858.jpg) The active community with many knowledgeable users. Whether you're a new user or an experienced one, you can find helpful information and advice from other members of the community. You can also share your experiences and insights, helping to contribute to the community as a whole. ### Reason 13: Self hosting Your Password Manager If you want to own your data If you're paranoid of someone storing your passwords, then this one is for you. Bitwarden provides with the option to self-host their password manager if they want to have complete control over their sensitive information. By self-hosting, you own your data and ensure that it is stored on your own servers, rather than on Bitwarden's servers. But only proceed with it, if you know how to host and manage files securely, I ain't a security expert, so I let Bitwarden store my details. As I mentioned, before about Zero Knowledge End encryption, so I don't worry about this part. — You can learn How to Self-host [from here](https://bitwarden.com/help/licensing-on-premise/#:~:text=Self%2Dhosting%20Bitwarden%20is%20free,the%20owner%20of%20an%20organization.). ### Reason 14: Vault Health Reports to Evaluate the Security of Current Passwords Bitwarden's vault health reports feature is a powerful tool that can help you to evaluate the security of your current passwords and suggest ways to improve them. By using this feature, you can ensure that your accounts are protected from potential threats. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Vault-Report-Bitwarden.jpg) For example, you can see if you have any weak passwords or if you are reusing passwords across multiple accounts. This level of detail can help you to identify potential security risks and take action to address them. I keep checking this to ensure that none of my passwords are insecure or leaked, and if I have to update any password if any company data is compromised. ## Conclusion I have found Bitwarden password manager to be an invaluable tool for keeping my online accounts secure. With the increasing prevalence of cyberattacks, it is essential to use a password manager to protect sensitive information. Bitwarden offers a variety of features that make it a great choice for businesses and individuals alike. Overall, I highly recommend Bitwarden password manager for anyone looking to improve their online security. Its robust features, ease of use, and excellent customer support make it an excellent choice for businesses and individuals alike. Hope you find this helpful, i will be writing more content on securing yourself and your details in this growing tech world. Let me know your thoughts in the comments below. --- # How to Manage WordPress Websites for Clients: Tips and Best Practices Source: https://adityaarsharma.com/how-to-manage-wordpress-websites-for-clients/ As a WordPress Developer who manages Servers for multiple websites, I have worked with many clients who require WordPress websites. One of the most important aspects of managing a WordPress website for clients is hosting. Choosing the right hosting provider and managing the website's hosting can make or break the website's performance and security. When it comes to hosting WordPress websites for clients, there are a few tips and best practices that I have found to be particularly useful. In this article, I will share my knowledge and experience on how to manage WordPress websites for clients, including tips and best practices for managing the website's hosting. ## 1. Selecting a WordPress Optimized Hosting Solution Choosing the right hosting plan for your client's WordPress website is crucial to ensure its success. With so many hosting providers and plans available, it can be overwhelming to make the right choice. Some providers I would suggest: - [Kinsta](https://kinsta.com/) - [Rocket.net](https://rocket.net/) - [Hostinger](https://www.hostinger.com/) - Personal I manage WordPress websites only for Agencies on Auto pilot – [*(Book a Free Meeting)*](https://portal.adityaarsharma.com/p/scheduler/MrNmGF4K5YXq4x7Ed) So, here are some tips to help you select the best WordPress optimized hosting solution for your client: ### Choosing the Right Hosting Plan for your Client ![](https://adityaarsharma.com/wp-content/uploads/2023/04/WordPress-Optimised-Hosting-From-Kinsta-1024x495.jpg) When selecting a hosting plan for your client's WordPress website, consider the following: - **Plan:** Look for a hosting provider that offers WordPress specific plans. These plans are optimized for WordPress websites and come with features like automatic updates and backups, as well as enhanced security and performance. - **Hosting Provider:** Choose a reputable hosting provider with a good track record of uptime and customer support. Look for reviews and recommendations from other WordPress users to help you make an informed decision. - **Cost:** Consider your client's budget when selecting a hosting plan. Keep in mind that cheaper plans may come with limited features and resources, while more expensive plans may offer more advanced features and better performance. It's also important to consider the specific needs of your client's website, such as the amount of traffic it receives, the size of its database, and the types of content it hosts. This will help you choose a hosting plan that can accommodate these needs and provide optimal performance for the website. By taking the time to select the right hosting plan for your client's WordPress website, you can ensure that it runs smoothly, is secure, and provides a positive user experience for visitors. ## 2. Choosing the Right WordPress Theme for Faster Performance I understand the importance of choosing the right WordPress theme for faster performance. Not only does it affect the user experience, but it can also impact search engine rankings. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Nexter-WordPress-Theme-Performance-Scores-1024x770.jpg) Some light Theme, I would suggest: - [GeneratePress](https://generatepress.com/) - [NexterWP Theme](https://nexterwp.com/) This 1st step after hosting your WordPress website will reduce most work of your Performance optimization. Choosing a Theme from developers who code cleanly and load things conditionally is a smart move. — [Read How to choose a Perfect WordPress Theme](https://www.wpbeginner.com/wp-themes/selecting-the-perfect-theme-for-wordpress/) ## 3. Securing Client's WordPress Website It is your responsibility to ensure that your client's website is secure. In today's digital world, website security is of utmost importance. Here are some tips to help you secure your client's WordPress website: ### Having an SSL Certificate with HTTPS One of the first things you should do is to ensure that your client's website has a valid SSL certificate. SSL (Secure Sockets Layer) is a security protocol that encrypts data between a website and its visitors. This is important because it prevents hackers from intercepting sensitive information such as login credentials and credit card details. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/SSL-on-My-Website.jpg) Having an SSL certificate also improves your client's website's search engine ranking. Google has been using HTTPS as a ranking signal since 2014, so having HTTPS will give your client's website a slight boost in search engine visibility. ### Taking Care of Security Hardening for your Client Websites Another important step in securing your client's WordPress website is to harden its security. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/IThemes-Security-Features-1024x495.jpg) Some Plugins I would suggest: - [Ithemes](https://ithemes.com/) - [Malcare](https://www.malcare.com/) Here are some best practices: - Keep WordPress, plugins, and themes updated - Use strong and unique passwords for all website-related services such as FTP and host logins - Limit login attempts - Use two-factor authentication - Disable file editing in WordPress - Install a security plugin such as Wordfence or Sucuri By following these best practices, you can significantly reduce the chances of your client's website being hacked. However, keep in mind that no website is completely secure, so it's important to have a backup plan in case of a security breach. There is more to WordPress Security, I will be writing a detailed blog on it soon. It should be enough to manage any enterprise grade website. ## 4. Optimizing WordPress Website for Best Performance of Client Websites When it comes to managing WordPress websites for clients, one of the most important things to consider is website performance. A slow website can lead to a poor user experience and can even affect search engine rankings. Therefore, it's crucial to optimize the website for the best possible performance. I will discuss two important parts that can help you optimize your client's website for the best performance. *Looking to make it easier for your readers to copy code snippets from your WordPress blog? C[heck out this tutorial](https://adityaarsharma.com/how-to-add-copy-code-button-to-wordpress-code-blocks/) on how to add a copy code button to your WordPress code blocks!* ### Choosing the Right Cache Plugin for Best Results Caching is an effective way to speed up a WordPress website. It involves storing frequently accessed data in a cache, so it can be quickly retrieved when needed. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/image-3-1024x533.png)*Image Source: Kinsta* There are many caching plugins available for WordPress, but not all of them are created equal. Choosing the right caching plugin can make a big difference in website performance. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/image-2-1024x571.png) One of the most popular caching plugins is WP Rocket. It's a premium plugin that offers a wide range of caching features, including page caching, browser caching, and object caching. WP Rocket also includes other performance optimization features, such as minification and lazy loading. Another popular caching plugin is W3 Total Cache, which is a free plugin that offers similar caching features. When choosing a caching plugin, it's important to consider your client's website needs and budget. WP Rocket is a great option for clients who are willing to pay for a premium plugin, while W3 Total Cache is a good option for clients on a tight budget. ### Debloating WordPress & Other Performance Optimization Another important aspect of website performance optimization is debloating WordPress. This involves removing unnecessary code, files, and plugins from the WordPress website. This can help reduce the website's load time and improve performance. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/WordPress-Debloater-Perfmatters-Settings-1024x715.jpg)*A popular WordPress Debloating Perfmatters Backend Settings* One way to debloat WordPress is by removing unused plugins and themes which you aren't using. And then using Plugins like [Perfmatters ](https://perfmatters.io/) on your current setup to remove unnecessary CSS and JS for each page. Take a look at how to set up Perfmatters, directly from the makers. https://www.youtube.com/watch?v=GqDzzimnRbE Other performance optimization techniques include optimizing images, reducing HTTP requests, and using a content delivery network (CDN). These techniques can help improve website performance and reduce load times. *Are you tired of dealing with the technical aspects of running your agency, like server management and troubleshooting? As your technical partner, I can take care of all the nitty-gritty details so that you can focus on scaling your agency and serving your clients. * *With my expertise, I can make everything run on autopilot, giving you peace of mind and freeing up your time to focus on what you do best. [Let's connect ](https://adityaarsharma.com/connect/)and see how I can help you take your agency to the next level.* ## 5. Optimizing Heavy Image Files for Client Websites We all know the importance of optimizing heavy image files for client websites. Not only does it improve the website's loading speed, but it also enhances the user experience. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Image-Optimization-WP-Compress-1024x668.jpg)*My WP Compress Dashboard* Here are some tips and best practices on how to optimize heavy image files for client websites. ### Image Optimization Image optimization is the process of reducing the file size of an image without compromising its quality. Here are some ways to optimize heavy image files: - Use image compression tools like[ TinyPNG](https://tinypng.com/) or [WP Compress](https://wpcompress.com/) to compress the image file size. - Resize the image to the appropriate dimensions before uploading it to the website. Mostly plugins like WP Compress automatically take care of it. - Use the appropriate image file format. For example, PNG is best for images with transparency, while JPEG is best for photographs. - Use lazy loading to improve the website's loading speed. This technique only loads the images when they are visible on the screen. ## 6. Managing WordPress Updates for your Client Website ### Managing WordPress Core Updates As a WordPress website host for clients, it is important to keep their website up-to-date with the latest version of WordPress. Updating WordPress can be a time-consuming task, especially when you have multiple client websites to update. To save time, I use bulk update tools like[ ManageWP](http://managewp.com/) or [MainWP. ](https://mainwp.com/) A quick glance at my Main WP Dashboard of the sites I manage. *I have compared 8 popular WordPress management tools with 40+ features, [check which WordPress Website Management tool is best for you.](https://adityaarsharma.com/best-8-wordpress-management-tools/)* ![](https://adityaarsharma.com/wp-content/uploads/2023/04/MainWP-to-Manage-Websites-1024x495.jpg) These tools allow me to update WordPress core for multiple websites at once, saving me time and effort. With these tools, I can easily schedule updates and ensure that all my client websites are running the latest version of WordPress. ### Plugin and Theme Updates In addition to updating WordPress core, it is also important to keep plugins and themes up-to-date. Outdated plugins and themes can pose a security risk to your client's website. Therefore, it is important to stay on top of updates for plugins and themes. Like WordPress core updates, I use bulk update tools to manage plugin and theme updates. These tools allow me to update plugins and themes for multiple websites at once, making the process quick and easy. To ensure that my client websites are running smoothly, I always test updates on a staging site before applying them to the live website. This allows me to catch any potential issues before they become a problem for my clients. Avoid using the default Auto-update feature from WordPress, as updating quickly to the latest version can break your website, often update only when you feel the plugin is safe to do so. ## 7. Setting Up SEO Plugin: Taking Care of SEO for Your Clients Search engine optimization (SEO) is an essential part of any website, and it's no different for WordPress sites. It's your responsibility to ensure that your clients' websites are optimized for search engines. One of the best ways to achieve this is by setting up an SEO plugin. ### Technical & On-Page SEO Best Practices When it comes to SEO, technical and on-page optimizations are crucial. Here are some best practices to consider: - Ensure that the website has a clean and organized structure - Optimize website speed and performance - Use proper heading tags (H1, H2, H3, etc.) - Add alt tags to images - Include meta descriptions and meta tags - Use relevant keywords in the content - Ensure that the website is mobile-friendly and responsive By implementing these best practices, you can help your clients' websites rank higher in search engine results. You can take a look at this helpful resource from the Ahref YouTube channel on Technical SEO https://youtu.be/_bewU9AvKwU ## 8. Backing Up Your Client WordPress Website Backups protect against data loss and ensures that your client's website can be restored in case of any issues or errors. Here, I will discuss the best practices for backing up your client's WordPress website. ### Backup Best Practices - Making 2 Backup Copies When it comes to backing up your client's WordPress website, it's always best to make two backup copies. This ensures that you have a backup of your backup, in case of any issues with the primary backup. Personally, I use the [All-in-One Migration](https://servmask.com/?locale=en) & Server-side Backup, and these 2 copies are enough to avoid nightmares ![](https://adityaarsharma.com/wp-content/uploads/2023/04/List-of-Features-of-Google-Drive-Backup-from-Servmask.jpg) Additionally, it's important to store the backup copies in separate locations, such as on a local server and a cloud storage service like Dropbox or Google Drive. There are several ways to back up a WordPress website, including using plugins or your host's native solution. Here are some backup best practices to consider: - Back up your website regularly, ideally daily or weekly depending on the frequency of updates. - Test your backup to ensure that it is complete and can be restored. - Keep your backups organized and labeled, so you can easily find the right backup when needed. - Store your backup in a secure location, away from any potential threats or vulnerabilities. ## 9. Submitting Monthly Reports to Clients One of the most important things you can do to keep your clients happy is to provide regular monthly reports. These reports should be clear, concise, and easy to understand without making too much cluttered. ### Adding Key Informations in Report When submitting monthly reports to clients, it's important to include key information that will help them understand how their website is performing. Some of the key information you should include in your report includes: - **Website Traffic:** This includes the number of visitors to the website, the number of page views, and the average time spent on the site. - **SEO Metrics: **This includes the website's search engine rankings, the number of backlinks, and any changes in these metrics over time. - **Security Metrics: **This includes any security incidents that occurred during the month and any steps taken to address them. - **Website Performance: **This includes information about the website's load times, uptime, and any issues that may have occurred during the month. - **Uptime Monitoring: **To inform clients if they have faced any downtimes, I use my favorite [Uptimerobot](https://uptimerobot.com/), which comes with multiple features like SSL monitoring and all, and it notifies via call, SMS whenever any website of my client faces issues By including this information in your monthly reports, you will help your clients understand how their website is performing and what steps you are taking to maintain and improve it. Another important thing to keep in mind when submitting monthly reports to clients is to use visual aids to help convey information. For example, you might include charts or graphs to show changes in website traffic over time or to highlight key performance metrics. *I hope you found this blog helpful, there's alot more to like debugging WordPress website, when your client faces issues, staying upto date with Performance and Security news so that your client's site remains safe and sound.* *If you're looking for a Technical Partner for your Agency to handle all the Server and Technical nitty-gritty, then feel free to [connect with me](https://adityaarsharma.com/connect/).* *I make everything run on autopilot for you so that you can focus on scaling your agency.* ## FAQs: Common Problems Agencies Face When Managing Client Websites When managing client websites, agencies may encounter various issues that can affect the website's performance and user experience. Here are some common questions and problems agencies face when managing client websites: ### How to deal with WordPress Security like client Website getting Hacked ? When it comes to dealing with WordPress security, it's important to take a proactive approach to prevent hacks from occurring in the first place. However, if a client's website does get hacked, there are steps you can take to clean it up and prevent future attacks. One option is to hire a security expert to clean up the site and implement additional security measures. Another option is to use a security plugin like MalCare, which can automatically clean up malware and provide ongoing protection against future attacks. If you or your client are dealing with a hacked WordPress site, I recommend taking immediate action to restore a clean backup of the site and implementing additional security measures to prevent future attacks. If you need further assistance, feel free to [connect with me](https://adityaarsharma.com/connect/) for additional support. ### How to manage WordPress Website content effectively? Managing website content can be overwhelming, especially if you have multiple websites. You can use a content management system (CMS) like WordPress to make it easier to manage content. You can also create a content calendar and plan ahead to ensure that your website content is consistent and up-to-date. ### How to stay up-to-date with WordPress website updates? Staying up-to-date with WordPress website updates is important for security and compatibility. You can enable automatic updates, backup your website, test updates on a staging site, check for updates regularly, and follow WordPress news and blogs to stay informed. By doing so, you can ensure that your website is secure and compatible with the latest technologies. ### How do I manage WordPress client Support Requests? To manage WordPress client requests effectively, it is important to set clear expectations, use a project management tool, prioritize requests, communicate effectively, and charge for additional services when needed. By following these guidelines, you can ensure that your clients are satisfied, maintain a positive relationship with them, and build a successful business. --- # How to Compress WordPress Images in Bulk for FREE (in Few Secs) Source: https://adityaarsharma.com/how-to-compress-wordpress-images-in-bulk/ Compressing images is a crucial step in optimizing your website's performance. It reduces the size of your image files, which in turn decreases the page load time and improves the overall user experience. However, compressing images one by one can be a time-consuming task, especially if you have a large number of images on your WordPress site. That's where bulkresizephotos.com comes in handy. I recently came across, a free online tool that allows you to compress multiple images at once. With this tool, you can easily reduce the size of your images without compromising on quality. The best part is that it's completely free to use, and you don't need to download any software or plugins to get started. ## Why Compress WordPress Images? After managing many WordPress websites, I understand the importance of optimizing my website for speed and performance. One of the most important aspects of website optimization is image compression. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Why-Compressing-WordPresss-Images-is-Important-1024x544.jpg) ### Benefits of Compressing Images #### Reduced File Size Images are an integral part of any website, but they can also be one of the biggest contributors to slow loading times. Large image files can take longer to load, which can negatively impact user experience and search engine rankings. By compressing images, you can reduce their file size without sacrificing quality, resulting in faster loading times and a better user experience. If you're an agency owner managing multiple websites, then I have documented everything you require to manage your client websites - [Read the Complete List here](https://adityaarsharma.com/how-to-manage-wordpress-websites-for-clients/) #### Improved Website Speed Website speed is a crucial factor in user experience and search engine rankings. Compressing images can significantly improve website speed, as smaller image files load faster. This can lead to a better user experience, increased engagement, and improved search engine rankings. #### Reduced Bandwidth Usage Compressing images can also help reduce bandwidth usage, which can be beneficial for website owners who have limited bandwidth or are on a shared hosting plan. By reducing the size of images, you can reduce the amount of data that needs to be transferred, resulting in lower bandwidth usage and potentially lower hosting costs. #### Better SEO Search engines like Google consider website speed as a ranking factor. A faster website can lead to better search engine rankings, resulting in more traffic and potential customers. Compressing images can significantly improve website speed, which can have a positive impact on your search engine rankings. ## How to Compress WordPress Images in Bulk for Free Now it's time for some action and let's optimize our images. ### Visiting Bulk Resize Photos Website The first step is to visit the [Bulk Resize Photos website](https://bulkresizephotos.com/en). It is a free online tool that allows you to compress images in bulk without compromising on quality. The website is user-friendly and easy to navigate, making the process of compressing images a breeze. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Bulk-Resize-Photos-Website-for-WordPress-1024x495.jpg) ### Uploading Photos to Bulkresizephotos.com (Everything Happens Locally) Once you are on the website, click on the Choose Image button. You can select multiple images at once, and the website will upload them. You can also drag and drop your images on this website. The best part is that everything happens locally, which means that your images are not uploaded to any server. The website uses your computer's processing power to compress the images, ensuring that your data is safe and secure. ### Downloading the Zip File of Compressed Images After the images are uploaded, you can select the compression level and image format. And various other settings. Normally, I go with Percentage % based compressions so that I don't apply a fixed same value for multiple image sizes ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Setting-Up-Bulk-Resize-Photos-1024x572.jpg) Once you have made your selection, click on the Start button. The website will compress the images, and you can download the zip file containing the compressed images. And from WordPress 5.8 supports [uploading WebP format](https://kinsta.com/blog/wordpress-5-8/), which can be beneficial in improving website loading times, in addition to optimizing image compression. Uploading images in WebP format can reduce file size while maintaining image quality, leading to faster website loading times. - You can learn more on WebP [from here.](https://developer.chrome.com/docs/lighthouse/performance/uses-webp-images/) *TIPS: A helpful tip for optimizing your setup is to save the URL under the “Automatic mode” heading once you've found the best configuration for you. This way, you can easily revisit the page with that URL and compress your content using the mode you've assigned, without having to manually adjust the settings each time.* ### Uploading the Optimized Size Images to WordPress Media Library The final step is to upload the compressed images to your WordPress site. You can do this by navigating to the “Media” section in your WordPress dashboard and clicking on “Add New." Select the compressed images from your computer and upload them to your website. ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Uploading-Images-in-WordPress-1024x342.jpg) Make sure to unzip the images and upload them individually by drag-and-drop or selecting all the image files. ## Why Use This in Place of Paid WordPress Image Optimization Plugins? There are many paid WordPress image optimization plugins available in the market, but why spend money when you can compress your images for free? Unlike paid plugins, you don't have to upload your files to any 3rd party server, and you don't have to perform any tasks on your local WordPress server and create timeouts, here everything happens locally on your browser. Using bulkresizephotos.com is a faster and more efficient way to optimize your images. It takes just a few seconds to compress hundreds of images, whereas plugins can take hours to perform the same task. Moreover, paid plugins can be expensive, and they may not be suitable for everyone. If you're just starting with your website, you may not have the budget to spend on a plugin. Bulkresizephotos.com is a free alternative that provides the same results. --- # Best Multivitamin for Vegetarians Under 500: Top Picks for Optimal Health Source: https://adityaarsharma.com/best-multivitamin-for-vegetarians-under-500/ Multivitamins are an essential part of a healthy lifestyle, especially for vegetarians who may not get all the necessary nutrients from their diet. However, with so many options available, it can be overwhelming to choose the right one. In this article, I will be discussing the best multivitamin for vegetarians under 500. When looking for a multivitamin, it's crucial to pay attention to the ingredients list. Vegetarians need to ensure that the multivitamin they choose does not contain any animal-derived ingredients such as gelatin or fish oil. Additionally, it's essential to look for a multivitamin that provides a balanced combination of vitamins and minerals to meet the body's needs. After researching and testing several options, we have identified the best multivitamin for vegetarians under 500. This multivitamin contains all the necessary vitamins and minerals, including iron, which is often lacking in vegetarian diets. It is also free from any animal-derived ingredients, making it suitable for all vegetarians. ## Best Multivitamins for Vegetarians under $500 As a health-conscious individual, I understand the importance of ensuring that my body receives all the essential nutrients it needs to function optimally. For vegetarians, it can be challenging to get all the necessary vitamins and minerals from their diet alone. That's why I've compiled a list of the best multivitamins for vegetarians under $500. These supplements are specifically formulated to meet the unique nutritional needs of vegetarians, providing them with all the essential vitamins and minerals they need to maintain good health. So, without further ado, let's dive into the list and find the perfect multivitamin for you! ### Zingavita Multivitamin Tablets ![Zingavita Multivitamin Tablets](https://m.media-amazon.com/images/I/719--eokLGL.jpg) I highly recommend Zingavita Multivitamin Tablets for vegetarians who want to maintain good health and immunity. **Pros** - The blend of 43 vitamins, minerals, and herbs provides complete nourishment for overall health and immunity. - The tablets have 100% RDA of vitamins and minerals, which boosts immunity, improves stamina, supports healthy bones, enhances cognitive and cardiovascular health, and promotes glowing and healthy skin. - The supplements are manufactured in an FSSAI approved, WHO-GMP & HACCP certified facility and are completely vegetarian, sugar-free, GMO-free, gluten-free, soy-free with no side-effects. **Cons** - The tablets need to be consumed on a daily basis to see effective results. - The size of the tablets is slightly bigger than other multivitamin tablets. - The price is slightly higher compared to other multivitamin tablets in the market. I have been using Zingavita Multivitamin Tablets for a few weeks now, and I can already see a noticeable difference in my energy levels and overall well-being. The tablets are easy to consume, and I take one tablet with my lunch every day. The tablets have a pleasant taste and do not leave any aftertaste. The blend of 43 vitamins, minerals, and herbs is a unique feature of Zingavita Multivitamin Tablets, and I can feel the difference in my immunity and stamina. The tablets have also improved my skin's texture and brightened my complexion. I appreciate that the supplements are manufactured in an FSSAI approved, WHO-GMP & HACCP certified facility, and are completely vegetarian, sugar-free, GMO-free, gluten-free, soy-free with no side-effects. This assures me that I am consuming a high-quality product that is safe and effective. [Buy Now on Amazon](https://www.amazon.in/Zingavita-Multivitamin-Tablets-Men-Women/dp/B09GW8HRS5?tag=adityaarsharm-21) ### Zingavita Multivitamin Tablets for Women ![Zingavita Multivitamin Tablets for Women](https://m.media-amazon.com/images/I/61CHvNsWpgL.jpg) If you're looking for a multivitamin specifically designed for women that is vegetarian, gluten-free, and sugar-free, then Zingavita Multivitamin Tablets for Women might be just what you need. **Pros** - The blend of vitamins, minerals, and herbal extracts in this supplement provides overall nourishment, boosts immunity, improves stamina, and supports healthier bones. - The powerful antioxidant blend with Green Tea & Curcumin Extract, Collagen, Biotin & Vitamin E is formulated especially for women to promote hair growth, glowing skin & healthy nails. - The tablets are easy to swallow and don't cause constipation. **Cons** - Some people might need to consult their doctor before taking this supplement. - The tablets might not be suitable for people with certain medical conditions or allergies. - The product might not be available in all regions. The tablets are easy to take, and I haven't experienced any side effects. The powerful blend of vitamins, minerals, and herbal extracts in this supplement provides overall nourishment, boosts immunity, improves stamina, and supports healthier bones. One of the things I love about this product is the powerful antioxidant blend with Green Tea & Curcumin Extract, Collagen, Biotin & Vitamin E, formulated especially for women to promote hair growth, glowing skin & healthy nails. I have noticed an improvement in my hair, skin, and nails since I started taking this supplement. Another thing I appreciate about Zingavita Multivitamin Tablets for Women is that they are manufactured in an FSSAI approved, WHO-GMP & HACCP certified facility. And they’re also vegetarian, sugar-free, GMO-free, gluten-free, and soy-free, leaving me totally stress-free. Overall, I highly recommend Zingavita Multivitamin Tablets for Women to any woman looking for a high-quality, vegetarian, gluten-free, and sugar-free multivitamin supplement. [Buy Now on Amazon](https://www.amazon.in/Zingavita-Multivitamin-Women-Vitamins-Extracts/dp/B09XXP7GB6?tag=adityaarsharm-21) ### Silverback Nutrition Multivitamin IGNIS ![Silverback Nutrition Multivitamin IGNIS](https://m.media-amazon.com/images/I/71zNjGR3vaL.jpg) I highly recommend the Silverback Nutrition Multivitamin IGNIS by DMK India Healthcare for vegetarians looking for a complete blend of vitamins, minerals, energy, immunity, and super antioxidants, prebiotics, and probiotics. **Pros** - The combination of 67 ingredients in 13 vital blends provides nourishment to vital organs for optimum function. - The added advantage of pre and probiotics improves digestion of your daily diet plan to give you desirable results. - The ashwagandha extract in this tablet makes it one of the best multivitamins available. **Cons** - The tablet is smaller in size, which may make it difficult for some people to swallow. - The taste is unflavored, which may not be appealing to everyone. - The product may not be suitable for those with certain dietary restrictions. The Silverback Nutrition Multivitamin IGNIS is a great choice for those looking for a vegetarian-friendly multivitamin. The combination of 67 ingredients in 13 vital blends provides nourishment to vital organs for optimum function, while the added advantage of pre and probiotics improves digestion of your daily diet plan to give you desirable results. The ashwagandha extract in this tablet makes it one of the best multivitamins available. In addition, this tablet is easy to take, with just one tablet a day providing all the necessary nutrients. The smaller size of the tablet makes it easy to swallow, and the unflavored taste ensures that it won't leave a bad taste in your mouth. However, the tablet may not be suitable for those with certain dietary restrictions, and the unflavored taste may not be appealing to everyone. Overall, the Silverback Nutrition Multivitamin IGNIS is a great choice for vegetarians looking for a complete multivitamin. [Buy Now on Amazon](https://www.amazon.in/Silverback-Multivitamin-DMK-Antioxidants-Testosterone/dp/B09LMDTVJW?tag=adityaarsharm-21) ## Buying Guide As a nutritionist, I always recommend that vegetarians take multivitamins to ensure that they are getting all the necessary nutrients. However, with so many options available, it can be challenging to choose the best one. Here are some factors to consider when choosing a multivitamin for vegetarians under 500. ### Nutrient Content When choosing a multivitamin, it is essential to check the nutrient content. Look for a product that provides at least 100% of the recommended daily value (DV) for essential vitamins and minerals such as vitamin B12, iron, and calcium. Vegetarians are more likely to be deficient in these nutrients, so it is crucial to ensure that the multivitamin you choose contains them in adequate amounts. ### Form of Nutrients The form of nutrients is another important factor to consider. Some multivitamins contain nutrients in their synthetic form, which may not be as easily absorbed by the body. Look for a product that contains nutrients in their natural form, such as methylcobalamin for vitamin B12 and chelated minerals. ### Additives and Allergens Check the label for any additives or allergens that may be present in the multivitamin. Some products may contain artificial colors, flavors, or preservatives that may not be suitable for vegetarians. Also, check for any allergens such as soy, gluten, or dairy that may be present in the product. ### Price Price is also an important factor to consider when choosing a multivitamin. Look for a product that fits within your budget but still provides all the essential nutrients. Remember that a higher price does not always guarantee a better product. ### Other Considerations Other factors to consider when choosing a multivitamin include the size and form of the pill, the recommended dosage, and the reputation of the manufacturer. Look for a product that is easy to swallow and comes in a convenient form. Also, check the recommended dosage and follow it strictly. In conclusion, choosing the best multivitamin for vegetarians under 500 requires careful consideration of several factors. By checking the nutrient content, form of nutrients, additives and allergens, price, and other considerations, you can find a product that meets your needs and helps you maintain optimal health. --- # WP Remote vs Main WP: 40+ Feature Comparisons Source: https://adityaarsharma.com/wp-remote-vs-main-wp/ I have always been on the lookout for the best site management tool to help me manage my websites effectively. Recently, I had the opportunity to compare two popular WordPress site management tools - WP Remote and Main WP. In this blog post, I will share my experience of comparing these two tools and highlight the 40 key features that set them apart. Whether you're a blogger, a business owner, or a web developer, this comparison will help you choose the best site management tool for your WordPress website. So, let's dive in and explore the differences between WP Remote and Main WP! ## What is WP Remote ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/WP-Remote-Dashboard.jpg) WP Remote is a powerful WordPress management tool that allows you to manage multiple WordPress sites from a single dashboard. With WP Remote, you can easily update plugins, themes, and WordPress core across all your sites with just a few clicks. You can also monitor your site's uptime, performance, and security, and receive alerts if anything goes wrong. WP Remote makes it easy to manage backups, restore your site, and even clone your site to a new location. Whether you're managing a single site or dozens of sites, WP Remote is a must-have tool for any WordPress website owner. ## What is Main WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/MainWP-to-Manage-Websites.jpg) MainWP is a powerful WordPress management tool that helps website owners and developers manage multiple WordPress sites from a single dashboard. With MainWP, you can easily update themes, plugins, and WordPress core across all your sites with just a few clicks. You can also manage user roles and permissions, schedule backups, and monitor site performance and security. MainWP makes it easy to keep your WordPress sites up-to-date and secure, saving you time and effort in the process. Whether you're managing a handful of sites or dozens, MainWP is a must-have tool for any WordPress professional. ## Comparison Table Between WP Remote vs Main WP: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between WP Remote and Main WP? After analyzing both WP Remote and MainWP, we recommend MainWP as the better option for WordPress management. MainWP offers a wide range of features that make it a powerful tool for managing multiple WordPress sites. Some of the key benefits of MainWP include: 1. Multi-site management: MainWP allows you to manage multiple WordPress sites from a single dashboard, saving you time and effort. 2. Security: MainWP offers advanced security features such as brute force protection, malware scanning, and two-factor authentication to keep your sites secure. 3. Customizable: MainWP is highly customizable, allowing you to tailor it to your specific needs and preferences. 4. User-friendly: MainWP is easy to use, even for beginners, with a user-friendly interface and intuitive navigation. While WP Remote also offers some useful features, such as automatic updates and backups, it falls short in terms of multi-site management and customization options. In summary, if you're looking for a powerful and customizable WordPress management tool that can handle multiple sites, MainWP is the way to go. --- # WP Remote vs Manage WP: 40+ Feature Comparisons Source: https://adityaarsharma.com/wp-remote-vs-manage-wp/ I understand the importance of managing multiple sites efficiently. That's why I decided to compare two popular WordPress site management tools - WP Remote and Manage WP. After going through their features, I found that both these tools have a lot to offer. In this blog, I will be sharing my experience of comparing the 40 features of WP Remote and Manage WP. By the end of this article, you will have a clear understanding of which tool suits your needs the best. So, let's dive in! ## What is WP Remote ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/WP-Remote-Dashboard.jpg) WP Remote is a powerful WordPress management tool that allows you to manage multiple WordPress sites from a single dashboard. With WP Remote, you can easily update plugins, themes, and WordPress core across all your sites with just a few clicks. You can also monitor your site's uptime, performance, and security, and receive alerts if anything goes wrong. WP Remote makes it easy to manage backups, restore your site, and even clone your site to a new location. Whether you're managing a single site or dozens of sites, WP Remote is a must-have tool for any WordPress website owner. ## What is Manage WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/managewp-dashboad.jpg) ManageWP is a powerful WordPress management tool that allows you to manage multiple WordPress websites from a single dashboard. With ManageWP, you can easily update plugins, themes, and WordPress core across all your sites with just a few clicks. You can also schedule backups, monitor uptime, and track website performance metrics all from one place. This tool is especially useful for website owners who manage multiple websites or for agencies that handle website maintenance for clients. With ManageWP, you can streamline your workflow and save time, so you can focus on growing your business. ## Comparison Table Between WP Remote vs Manage WP: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between WP Remote and Manage WP? When it comes to choosing between WP Remote and Manage WP, it ultimately depends on your specific needs and preferences. However, based on their features and capabilities, we can provide some guidance to help you make an informed decision. If you're looking for a user-friendly WordPress management tool that's easy to set up and use, WP Remote may be the better choice for you. It offers a simple interface that lets you manage multiple WordPress sites from one dashboard, and it also includes automatic backups, security scanning, and plugin updates. On the other hand, if you need more advanced features and customization options, Manage WP may be the better option. It offers more granular control over your WordPress sites, with features like client reporting, SEO analysis, and performance monitoring. It also integrates with other tools like Google Analytics and Dropbox. Ultimately, both WP Remote and Manage WP are great options for managing your WordPress sites, and it's up to you to decide which one best fits your needs. We hope this guide has been helpful in making your decision! --- # WP Remote vs WP Umbrella: 40+ Feature Comparisons Source: https://adityaarsharma.com/wp-remote-vs-wp-umbrella/ As a WordPress user, I understand the importance of having a reliable site management tool. Recently, I had the opportunity to compare two popular options: WP Remote and WP Umbrella. In this post, I'll be sharing my experience and insights on the 40 feature comparisons I made between these two tools. Whether you're a blogger, business owner, or developer, this SEO optimized blog post will help you make an informed decision on which WordPress site management tool is best for you. So, let's dive in and explore the differences between WP Remote and WP Umbrella! ## What is WP Remote ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/WP-Remote-Dashboard.jpg) WP Remote is a powerful WordPress management tool that allows you to manage multiple WordPress sites from a single dashboard. With WP Remote, you can easily update plugins, themes, and WordPress core across all your sites with just a few clicks. You can also monitor your site's uptime, performance, and security, and receive alerts if anything goes wrong. WP Remote makes it easy to manage backups, restore your site, and even clone your site to a new location. Whether you're managing a single site or dozens of sites, WP Remote is a must-have tool for any WordPress website owner. ## What is WP Umbrella ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/wp-umbrealla-WordPress-website-manager-tool.png) WP Umbrella is a powerful WordPress management tool that helps website owners and developers to monitor and manage their WordPress websites with ease. This tool provides a wide range of features that allow users to keep track of their website's performance, security, and uptime. With WP Umbrella, users can monitor their website's uptime and receive instant alerts in case of any downtime. They can also track their website's performance metrics, such as page load times and server response times, and optimize their website accordingly. Moreover, WP Umbrella also provides advanced security features that help users to protect their website from malware attacks, brute force attacks, and other security threats. Overall, WP Umbrella is an all-in-one WordPress management tool that simplifies website management and helps users to keep their website secure, fast, and reliable. ## Comparison Table Between WP Remote vs WP Umbrella: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between WP Remote and WP Umbrella? Based on our analysis, we recommend WP Remote for those who are looking for a reliable and cost-effective WordPress management tool. WP Remote offers a wide range of features that can help you manage your WordPress websites with ease, including automatic updates, backups, and security checks. On the other hand, WP Umbrella is a more advanced tool that is suitable for larger businesses and enterprises that require more robust security and monitoring features. WP Umbrella offers real-time monitoring, advanced security features, and detailed reporting, making it a great choice for businesses that value security and data privacy. Ultimately, the choice between WP Remote and WP Umbrella depends on your specific needs and budget. If you are looking for an affordable and reliable WordPress management tool, WP Remote is a great choice. If you require more advanced security and monitoring features, WP Umbrella is the way to go. --- # WP Remote vs Infinite WP: 40+ Feature Comparisons Source: https://adityaarsharma.com/wp-remote-vs-infinite-wp/ As a WordPress site owner, managing multiple websites can be a daunting task. That's why I decided to compare two popular WordPress site management tools: WP Remote and Infinite WP. In this blog post, I will share my experience after comparing 40 features of these two tools. So if you're looking for an efficient way to manage your WordPress sites, keep reading to see which tool comes out on top. ## What is WP Remote ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/WP-Remote-Dashboard.jpg) WP Remote is a powerful WordPress management tool that allows you to manage multiple WordPress sites from a single dashboard. With WP Remote, you can easily update plugins, themes, and WordPress core across all your sites with just a few clicks. You can also monitor your site's uptime, performance, and security, and receive alerts if anything goes wrong. WP Remote makes it easy to manage backups, restore your site, and even clone your site to a new location. Whether you're managing a single site or dozens of sites, WP Remote is a must-have tool for any WordPress website owner. ## What is Infinite WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Infinite-WP-Dashboard.jpg) Infinite WP is a powerful WordPress management tool that allows users to manage multiple WordPress sites from one central location. This tool is incredibly useful for bloggers, web developers, and anyone who manages multiple WordPress sites. With Infinite WP, you can perform updates, backups, and security checks on all your sites with just a few clicks. This tool also allows you to monitor your site's performance and receive notifications if anything goes wrong. Overall, Infinite WP is an essential tool for anyone who wants to streamline their WordPress management process and ensure their sites are always up-to-date and secure. ## Comparison Table Between WP Remote vs Infinite WP: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between WP Remote and Infinite WP? If you're trying to decide between WP Remote and Infinite WP for managing your WordPress site, there are a few factors to consider. First, WP Remote is a cloud-based solution that offers a user-friendly interface and a range of features for managing multiple WordPress sites from a single dashboard. It also has some useful automation tools, such as automatic updates and backups, that can save you time and effort. On the other hand, Infinite WP is a self-hosted solution that gives you more control over your site management. It also offers a range of plugins and add-ons that can extend its functionality, such as a client reporting tool and a malware scanner. Ultimately, the choice between these two tools will depend on your specific needs and preferences. If you're looking for a simple, cloud-based solution with useful automation features, WP Remote may be the better choice. But if you want more control over your site management and are willing to set up a self-hosted solution, Infinite WP may be the better option. So, to summarize, if you're looking for a cloud-based solution with a user-friendly interface and automation features, WP Remote is a great choice. But if you want more control over your site management and are willing to set up a self-hosted solution, Infinite WP may be the better option. --- # WP Remote vs Ithemes Sync: 40+ Feature Comparisons Source: https://adityaarsharma.com/wp-remote-vs-ithemes-sync/ As someone who manages multiple WordPress sites, I'm always on the lookout for tools that can help me streamline my workflow and make my life easier. Two products that I recently came across are WP Remote and Ithemes Sync. Both of these tools offer site management features that can save me time and effort, but which one is the better choice? In this post, I'll be comparing WP Remote and Ithemes Sync in detail, highlighting 40 key features and giving you my honest opinion on which one comes out on top. So if you're considering investing in a WordPress site management tool, read on to find out which one is right for you. ## What is WP Remote ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/WP-Remote-Dashboard.jpg) WP Remote is a powerful WordPress management tool that allows you to manage multiple WordPress sites from a single dashboard. With WP Remote, you can easily update plugins, themes, and WordPress core across all your sites with just a few clicks. You can also monitor your site's uptime, performance, and security, and receive alerts if anything goes wrong. WP Remote makes it easy to manage backups, restore your site, and even clone your site to a new location. Whether you're managing a single site or dozens of sites, WP Remote is a must-have tool for any WordPress website owner. ## What is Ithemes Sync ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Itheme-Sync-Dashboard.gif) If you're looking for an efficient way to manage multiple WordPress sites, then iThemes Sync might just be the tool for you. This powerful WordPress management tool allows you to easily manage all of your WordPress sites from a single dashboard, saving you time and hassle. With iThemes Sync, you can update themes and plugins across multiple sites with just a few clicks, and even schedule backups and security scans to keep your sites safe and secure. Whether you're a blogger, developer, or business owner, iThemes Sync is a must-have tool for anyone who wants to simplify their WordPress management tasks and focus on what really matters - creating great content and growing their online presence. ## Comparison Table Between WP Remote vs Ithemes Sync: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between WP Remote and Ithemes Sync? After thorough research and analysis, we recommend WP Remote as the better WordPress management tool between the two. WP Remote offers a user-friendly interface and a wide range of features that make it easy to manage multiple WordPress sites at once. With WP Remote, you can monitor your site's performance, update plugins and themes, and even schedule backups to ensure your site is always up-to-date and secure. On the other hand, while Ithemes Sync also offers some great features, it can be a bit more complex to use, especially for beginners. Ithemes Sync is better suited for more experienced users who need advanced features like syncing data between multiple sites and managing security settings. Overall, both tools have their strengths and weaknesses, but WP Remote is the better choice for most users due to its ease of use and comprehensive feature set. Whatever tool you choose, make sure it fits your specific needs and helps you manage your WordPress sites effectively. --- # WP Remote vs Updraft Central: 40+ Feature Comparisons Source: https://adityaarsharma.com/wp-remote-vs-updraft-central/ As someone who manages multiple WordPress sites, I understand the importance of having a reliable site management tool. Two popular options in the market are WP Remote and Updraft Central. After comparing the features of both tools, I've compiled a list of 40 comparisons to help you make an informed decision on which one to choose. Whether you're a beginner or an experienced WordPress user, this comparison will provide valuable insights into the strengths and weaknesses of each tool. So, let's dive in! ## What is WP Remote ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/WP-Remote-Dashboard.jpg) WP Remote is a powerful WordPress management tool that allows you to manage multiple WordPress sites from a single dashboard. With WP Remote, you can easily update plugins, themes, and WordPress core across all your sites with just a few clicks. You can also monitor your site's uptime, performance, and security, and receive alerts if anything goes wrong. WP Remote makes it easy to manage backups, restore your site, and even clone your site to a new location. Whether you're managing a single site or dozens of sites, WP Remote is a must-have tool for any WordPress website owner. ## What is Updraft Central ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Updraft-Central-Dashboard.jpg) Updraft Central is a powerful WordPress management tool that allows website owners and developers to easily manage and monitor multiple WordPress sites from a single dashboard. With Updraft Central, you can perform a range of tasks, including updating plugins and themes, creating backups, and monitoring site performance. One of the most impressive features of Updraft Central is its ability to manage multiple WordPress sites simultaneously. This can save you a lot of time and effort, especially if you manage several sites. You can easily perform updates, backups, and other tasks across all your sites with just a few clicks. Another great feature is the ability to create custom groups of sites. This is particularly useful if you have a large number of sites to manage, as it allows you to organize them into logical groups based on factors such as client, website type, or geographic location. Overall, Updraft Central is an essential tool for anyone who wants to streamline their WordPress management process. Its intuitive interface and powerful features make it a must-have for website owners and developers alike. ## Comparison Table Between WP Remote vs Updraft Central: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between WP Remote and Updraft Central? After carefully reviewing both WP Remote and Updraft Central, we recommend WP Remote for its user-friendly interface and comprehensive features. With WP Remote, you can easily manage multiple WordPress sites from a single dashboard, perform backups, updates, and security checks, and receive real-time alerts if anything goes wrong. Moreover, WP Remote offers a free plan with basic features, making it a great option for small businesses or individuals on a tight budget. On the other hand, Updraft Central is a powerful tool that offers advanced customization options and granular control over your WordPress sites. With Updraft Central, you can create custom workflows, schedule backups and updates, and manage plugins and themes with ease. However, its complexity may not be suitable for beginners or those who prefer a simpler approach. Ultimately, the choice between WP Remote and Updraft Central depends on your specific needs and preferences. If you're looking for a straightforward and easy-to-use solution to manage your WordPress sites, WP Remote is an excellent choice. But if you need more advanced features and customization options, Updraft Central may be the better fit. --- # WP Remote vs WPMU Dev Hub: 40+ Feature Comparisons Source: https://adityaarsharma.com/wp-remote-vs-wpmu-dev-hub/ As a WordPress user, I know how important it is to have a reliable site management tool that can help me manage my website with ease. That's why I decided to compare two of the most popular WordPress site management tools - WP Remote and WPMU Dev Hub. In this blog post, I'll be sharing my experience after comparing the features of these tools. With over 40 feature comparisons, you'll be able to see which tool is best suited for your needs. So, let's dive in and see how these two products stack up against each other. ## What is WP Remote ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/WP-Remote-Dashboard.jpg) WP Remote is a powerful WordPress management tool that allows you to manage multiple WordPress sites from a single dashboard. With WP Remote, you can easily update plugins, themes, and WordPress core across all your sites with just a few clicks. You can also monitor your site's uptime, performance, and security, and receive alerts if anything goes wrong. WP Remote makes it easy to manage backups, restore your site, and even clone your site to a new location. Whether you're managing a single site or dozens of sites, WP Remote is a must-have tool for any WordPress website owner. ## What is WPMU Dev Hub ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/WP-Mu-Dev-Hub.jpg) WPMU Dev Hub is a powerful WordPress management tool that can help you streamline your website management tasks. With WPMU Dev Hub, you can easily manage multiple WordPress sites from a single dashboard, saving you time and effort. This tool offers a range of features including site backups, security scans, performance optimization, and more. Whether you're a blogger, marketer, or website owner, WPMU Dev Hub can help you keep your WordPress sites running smoothly and efficiently. So, if you're looking for a comprehensive WordPress management solution, WPMU Dev Hub is definitely worth checking out. ## Comparison Table Between WP Remote vs WPMU Dev Hub: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between WP Remote and WPMU Dev Hub? When it comes to choosing between WP Remote and WPMU Dev Hub, it really depends on your specific needs and preferences. Both tools offer a range of features that can help you manage your WordPress sites more efficiently, but they differ in certain aspects that may make one more suitable for you than the other. If you're looking for a tool that's easy to use and offers a streamlined interface, WP Remote is a great choice. It's designed to simplify the process of managing multiple WordPress sites, allowing you to update plugins, themes, and core files with just a few clicks. Additionally, WP Remote offers a range of security features, including automatic backups and malware scanning, to help keep your sites safe and secure. On the other hand, if you're looking for a more comprehensive tool that offers a wider range of features, WPMU Dev Hub may be the better choice for you. In addition to site management features like updates and backups, WPMU Dev Hub also offers a range of development tools, including a custom CSS editor and a site cloning feature. It also includes access to a library of premium plugins and themes, which can save you money if you're looking to use those products on your sites. Ultimately, the choice between WP Remote and WPMU Dev Hub will depend on your specific needs and preferences. If you're looking for a simple, easy-to-use tool for managing your WordPress sites, WP Remote is a great choice. If you need a more comprehensive tool that offers a wider range of features and development tools, WPMU Dev Hub may be the better option for you. --- # Main WP vs WP Umbrella: 40+ Feature Comparisons Source: https://adityaarsharma.com/main-wp-vs-wp-umbrella/ Hey there, fellow WordPress enthusiasts! Today, I want to share my experience of comparing two powerful WordPress site management tools - MainWP and WP Umbrella. As a website owner or a developer, you know how important it is to have a reliable tool that can help you manage multiple WordPress sites efficiently. That's where MainWP and WP Umbrella come in. Both products offer a range of features that can make your life easier, but which one is the best fit for you? In this blog, I'll be comparing 40 key features of both tools, so you can make an informed decision and choose the right one for your needs. So, let's dive in! ## What is Main WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/MainWP-to-Manage-Websites.jpg) MainWP is a powerful WordPress management tool that helps website owners and developers manage multiple WordPress sites from a single dashboard. With MainWP, you can easily update themes, plugins, and WordPress core across all your sites with just a few clicks. You can also manage user roles and permissions, schedule backups, and monitor site performance and security. MainWP makes it easy to keep your WordPress sites up-to-date and secure, saving you time and effort in the process. Whether you're managing a handful of sites or dozens, MainWP is a must-have tool for any WordPress professional. ## What is WP Umbrella ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/wp-umbrealla-WordPress-website-manager-tool.png) WP Umbrella is a powerful WordPress management tool that helps website owners and developers to monitor and manage their WordPress websites with ease. This tool provides a wide range of features that allow users to keep track of their website's performance, security, and uptime. With WP Umbrella, users can monitor their website's uptime and receive instant alerts in case of any downtime. They can also track their website's performance metrics, such as page load times and server response times, and optimize their website accordingly. Moreover, WP Umbrella also provides advanced security features that help users to protect their website from malware attacks, brute force attacks, and other security threats. Overall, WP Umbrella is an all-in-one WordPress management tool that simplifies website management and helps users to keep their website secure, fast, and reliable. ## Comparison Table Between Main WP vs WP Umbrella: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between Main WP and WP Umbrella? When it comes to choosing between MainWP and WP Umbrella, it really depends on your specific needs and preferences. MainWP is a great option if you're looking for a powerful and flexible WordPress management tool that allows you to manage multiple sites from a single dashboard. It offers a wide range of features, including automated backups, security scans, plugin and theme management, and more. MainWP is also highly customizable, so you can tailor it to your specific needs. On the other hand, WP Umbrella is a newer player in the WordPress management space, but it's quickly gaining popularity thanks to its user-friendly interface and comprehensive set of features. WP Umbrella offers real-time site monitoring, automated backups, security scans, and more. It also includes advanced features like uptime monitoring and performance analytics. Ultimately, the choice between MainWP and WP Umbrella comes down to your specific needs and preferences. If you're looking for a more established and customizable solution, MainWP may be the way to go. If you're looking for a more user-friendly and comprehensive option, WP Umbrella may be the better choice. --- # Main WP vs Infinite WP: 40+ Feature Comparisons Source: https://adityaarsharma.com/main-wp-vs-infinite-wp/ Hey there, fellow WordPress enthusiasts! Today, I want to share my experience of comparing two of the most popular WordPress site management tools - MainWP and InfiniteWP. As someone who has been managing multiple WordPress sites for a while now, I understand the importance of having a reliable and efficient tool to manage them all. That's why I decided to do a feature-by-feature comparison of MainWP and InfiniteWP. In this blog post, I'll be sharing my findings and insights on the 40 key features that these two tools offer, so you can make an informed decision on which one is best suited for your needs. So, without further ado, let's dive into the comparison! ## What is Main WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/MainWP-to-Manage-Websites.jpg) MainWP is a powerful WordPress management tool that helps website owners and developers manage multiple WordPress sites from a single dashboard. With MainWP, you can easily update themes, plugins, and WordPress core across all your sites with just a few clicks. You can also manage user roles and permissions, schedule backups, and monitor site performance and security. MainWP makes it easy to keep your WordPress sites up-to-date and secure, saving you time and effort in the process. Whether you're managing a handful of sites or dozens, MainWP is a must-have tool for any WordPress professional. ## What is Infinite WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Infinite-WP-Dashboard.jpg) Infinite WP is a powerful WordPress management tool that allows users to manage multiple WordPress sites from one central location. This tool is incredibly useful for bloggers, web developers, and anyone who manages multiple WordPress sites. With Infinite WP, you can perform updates, backups, and security checks on all your sites with just a few clicks. This tool also allows you to monitor your site's performance and receive notifications if anything goes wrong. Overall, Infinite WP is an essential tool for anyone who wants to streamline their WordPress management process and ensure their sites are always up-to-date and secure. ## Comparison Table Between Main WP vs Infinite WP: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between Main WP and Infinite WP? If you are looking for a WordPress management tool, both MainWP and InfiniteWP are great options to consider. However, based on their features and capabilities, one may be a better fit for your needs than the other. MainWP is a free, open-source tool that allows you to manage multiple WordPress sites from a single dashboard. It is easy to use and comes with a range of features, including site management, updates, backups, and security. One of the main advantages of MainWP is its flexibility, as it allows you to customize your dashboard and add or remove extensions as needed. On the other hand, InfiniteWP is a paid tool that also allows you to manage multiple WordPress sites from a single dashboard. It offers similar features to MainWP, but also includes some additional ones like uptime monitoring and performance optimization. InfiniteWP is known for its user-friendly interface and intuitive navigation, making it a great choice for beginners or those who prefer a simpler interface. So, which one should you choose? If you are looking for a free and flexible tool that can handle basic WordPress management tasks, MainWP is a great option. However, if you are willing to pay for a more comprehensive tool with additional features, InfiniteWP may be the better choice for you. Ultimately, the decision depends on your specific needs and preferences. Both MainWP and InfiniteWP are great options that can help you manage your WordPress sites more efficiently, so it's worth considering which one will work best for you. --- # Main WP vs Ithemes Sync: 40+ Feature Comparisons Source: https://adityaarsharma.com/main-wp-vs-ithemes-sync/ As a WordPress user, managing multiple websites can be a daunting task. That's why I decided to compare two popular WordPress site management tools - MainWP and iThemes Sync. In this article, I'll be sharing my experience after comparing the features of these two tools. With over 40 feature comparisons, you'll get an in-depth look at what each tool has to offer and which one might be the best fit for your WordPress site management needs. So, let's dive in and see how MainWP and iThemes Sync stack up against each other. ## What is Main WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/MainWP-to-Manage-Websites.jpg) MainWP is a powerful WordPress management tool that helps website owners and developers manage multiple WordPress sites from a single dashboard. With MainWP, you can easily update themes, plugins, and WordPress core across all your sites with just a few clicks. You can also manage user roles and permissions, schedule backups, and monitor site performance and security. MainWP makes it easy to keep your WordPress sites up-to-date and secure, saving you time and effort in the process. Whether you're managing a handful of sites or dozens, MainWP is a must-have tool for any WordPress professional. ## What is Ithemes Sync ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Itheme-Sync-Dashboard.gif) If you're looking for an efficient way to manage multiple WordPress sites, then iThemes Sync might just be the tool for you. This powerful WordPress management tool allows you to easily manage all of your WordPress sites from a single dashboard, saving you time and hassle. With iThemes Sync, you can update themes and plugins across multiple sites with just a few clicks, and even schedule backups and security scans to keep your sites safe and secure. Whether you're a blogger, developer, or business owner, iThemes Sync is a must-have tool for anyone who wants to simplify their WordPress management tasks and focus on what really matters - creating great content and growing their online presence. ## Comparison Table Between Main WP vs Ithemes Sync: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between Main WP and Ithemes Sync? Both MainWP and iThemes Sync are excellent WordPress management tools that can help you manage multiple WordPress sites from a single dashboard. However, there are some differences between the two that can help you make a decision. If you are looking for a free WordPress management tool, then MainWP might be the better option for you. It offers a wide range of features that can help you manage multiple WordPress sites with ease. With MainWP, you can update WordPress core, themes, and plugins, manage backups, and monitor website uptime. On the other hand, if you are looking for a more robust WordPress management tool with advanced features, then iThemes Sync might be the better option for you. iThemes Sync offers a range of features that can help you manage and secure your WordPress sites, including two-factor authentication, malware scanning, and uptime monitoring. Overall, both MainWP and iThemes Sync are excellent WordPress management tools, and the choice between the two depends on your specific needs and budget. If you are looking for a free option with basic features, then MainWP is a great choice. However, if you need advanced features and are willing to pay for them, then iThemes Sync might be the better option for you. --- # Main WP vs Updraft Central: 40+ Feature Comparisons Source: https://adityaarsharma.com/main-wp-vs-updraft-central/ As a WordPress site owner, managing multiple websites can be a daunting task. That's why I decided to compare two popular WordPress site management tools: MainWP and Updraft Central. In this article, I will share my experience after comparing 40 features of both tools. Whether you're a beginner or an experienced WordPress user, this article will help you make an informed decision on which tool to choose for managing your WordPress sites. So, let's dive in and compare MainWP vs Updraft Central! But before we do that, let's take a quick look at what these tools are all about. ## What is Main WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/MainWP-to-Manage-Websites.jpg) MainWP is a powerful WordPress management tool that helps website owners and developers manage multiple WordPress sites from a single dashboard. With MainWP, you can easily update themes, plugins, and WordPress core across all your sites with just a few clicks. You can also manage user roles and permissions, schedule backups, and monitor site performance and security. MainWP makes it easy to keep your WordPress sites up-to-date and secure, saving you time and effort in the process. Whether you're managing a handful of sites or dozens, MainWP is a must-have tool for any WordPress professional. ## What is Updraft Central ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Updraft-Central-Dashboard.jpg) Updraft Central is a powerful WordPress management tool that allows website owners and developers to easily manage and monitor multiple WordPress sites from a single dashboard. With Updraft Central, you can perform a range of tasks, including updating plugins and themes, creating backups, and monitoring site performance. One of the most impressive features of Updraft Central is its ability to manage multiple WordPress sites simultaneously. This can save you a lot of time and effort, especially if you manage several sites. You can easily perform updates, backups, and other tasks across all your sites with just a few clicks. Another great feature is the ability to create custom groups of sites. This is particularly useful if you have a large number of sites to manage, as it allows you to organize them into logical groups based on factors such as client, website type, or geographic location. Overall, Updraft Central is an essential tool for anyone who wants to streamline their WordPress management process. Its intuitive interface and powerful features make it a must-have for website owners and developers alike. ## Comparison Table Between Main WP vs Updraft Central: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between Main WP and Updraft Central? After considering the features and benefits of both MainWP and Updraft Central, we recommend choosing MainWP as your WordPress management tool. Here's why: MainWP offers a comprehensive set of features that make it an excellent choice for managing multiple WordPress sites. With MainWP, you can manage all of your sites from a single dashboard, which saves you time and makes it easier to keep everything organized. You can also perform bulk updates, backups, and security checks, which is especially useful if you have a large number of sites to manage. In addition, MainWP is highly customizable, which means you can tailor it to your specific needs. You can choose from a wide range of extensions that add extra functionality, such as the ability to manage WooCommerce sites or perform advanced SEO checks. While Updraft Central is also a solid choice for WordPress management, it doesn't offer quite as many features as MainWP. However, it does have some unique benefits. For example, Updraft Central offers a more streamlined interface that's easy to use, even if you're not a technical expert. It also offers a free version, which is a good option if you're on a tight budget. Ultimately, the choice between MainWP and Updraft Central will depend on your specific needs and preferences. However, we believe that MainWP offers the most comprehensive set of features and benefits, making it the best choice for most users. --- # Main WP vs WPMU Dev Hub: 40+ Feature Comparisons Source: https://adityaarsharma.com/main-wp-vs-wpmu-dev-hub/ Hey there, fellow WordPress enthusiasts! Today, I want to share my experience with two popular WordPress site management tools - Main WP and WPMU Dev Hub. As a website owner, I understand the importance of having a reliable and efficient tool to manage my WordPress sites. That's why I decided to compare these two products and share my findings with you. In this article, I'll be discussing 40 feature comparisons between Main WP and WPMU Dev Hub, highlighting the strengths and weaknesses of each product. So, if you're in the market for a WordPress site management tool, keep reading to find out which one is the best fit for you! ## What is Main WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/MainWP-to-Manage-Websites.jpg) MainWP is a powerful WordPress management tool that helps website owners and developers manage multiple WordPress sites from a single dashboard. With MainWP, you can easily update themes, plugins, and WordPress core across all your sites with just a few clicks. You can also manage user roles and permissions, schedule backups, and monitor site performance and security. MainWP makes it easy to keep your WordPress sites up-to-date and secure, saving you time and effort in the process. Whether you're managing a handful of sites or dozens, MainWP is a must-have tool for any WordPress professional. ## What is WPMU Dev Hub ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/WP-Mu-Dev-Hub.jpg) WPMU Dev Hub is a powerful WordPress management tool that can help you streamline your website management tasks. With WPMU Dev Hub, you can easily manage multiple WordPress sites from a single dashboard, saving you time and effort. This tool offers a range of features including site backups, security scans, performance optimization, and more. Whether you're a blogger, marketer, or website owner, WPMU Dev Hub can help you keep your WordPress sites running smoothly and efficiently. So, if you're looking for a comprehensive WordPress management solution, WPMU Dev Hub is definitely worth checking out. ## Comparison Table Between Main WP vs WPMU Dev Hub: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between Main WP and WPMU Dev Hub? In conclusion, both MainWP and WPMU Dev Hub are excellent WordPress management tools that can help you streamline your website management tasks. However, depending on your specific needs and preferences, one tool may be better suited for you than the other. If you're looking for a straightforward, no-frills solution that allows you to manage multiple WordPress sites from a single dashboard, MainWP is a great choice. It's easy to use, affordable, and has all the essential features you need to manage your sites efficiently. On the other hand, if you're looking for a more comprehensive solution that includes additional features like site backups, security scans, and performance optimization, WPMU Dev Hub might be the better option for you. While it's a bit pricier than MainWP, it offers a wider range of tools and services that can help you keep your sites running smoothly. Ultimately, the choice between MainWP and WPMU Dev Hub will depend on your specific needs and budget. We hope this buyer's guide has helped you make an informed decision and find the WordPress management tool that's right for you. --- # Manage WP vs WP Umbrella: 40+ Feature Comparisons Source: https://adityaarsharma.com/manage-wp-vs-wp-umbrella/ As a WordPress site owner, managing multiple websites can be a daunting task. That's why I decided to compare two popular WordPress site management tools, Manage WP and WP Umbrella. Both tools offer a range of features that can help you manage your WordPress sites more efficiently. In this article, I'll share my experience of comparing the 40 key features of Manage WP and WP Umbrella, and help you decide which tool is the right fit for your website management needs. So, let's dive in and see how these two tools stack up against each other. ## What is Manage WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/managewp-dashboad.jpg) ManageWP is a powerful WordPress management tool that allows you to manage multiple WordPress websites from a single dashboard. With ManageWP, you can easily update plugins, themes, and WordPress core across all your sites with just a few clicks. You can also schedule backups, monitor uptime, and track website performance metrics all from one place. This tool is especially useful for website owners who manage multiple websites or for agencies that handle website maintenance for clients. With ManageWP, you can streamline your workflow and save time, so you can focus on growing your business. ## What is WP Umbrella ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/wp-umbrealla-WordPress-website-manager-tool.png) WP Umbrella is a powerful WordPress management tool that helps website owners and developers to monitor and manage their WordPress websites with ease. This tool provides a wide range of features that allow users to keep track of their website's performance, security, and uptime. With WP Umbrella, users can monitor their website's uptime and receive instant alerts in case of any downtime. They can also track their website's performance metrics, such as page load times and server response times, and optimize their website accordingly. Moreover, WP Umbrella also provides advanced security features that help users to protect their website from malware attacks, brute force attacks, and other security threats. Overall, WP Umbrella is an all-in-one WordPress management tool that simplifies website management and helps users to keep their website secure, fast, and reliable. ## Comparison Table Between Manage WP vs WP Umbrella: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between Manage WP and WP Umbrella? When it comes to choosing between Manage WP and WP Umbrella, there are a few key factors to consider. First, let's take a look at Manage WP. This tool is a great option for those who are looking for a comprehensive WordPress management solution. With Manage WP, you can easily manage multiple WordPress sites from a single dashboard, including updates, backups, security, and more. It also offers a range of useful features, such as site cloning and staging, client reporting, and team management. On the other hand, WP Umbrella is a newer tool that focuses specifically on website monitoring and uptime. It offers real-time monitoring of your WordPress site, as well as alerts and notifications if any issues arise. It also provides detailed reports on your site's performance and uptime, giving you valuable insights into how your site is performing. So which one should you choose? Ultimately, it depends on your specific needs and priorities. If you're looking for a comprehensive WordPress management tool that can handle everything from updates to backups to security, then Manage WP is likely the better option for you. However, if you're primarily concerned with website monitoring and uptime, then WP Umbrella may be the better choice. Overall, both Manage WP and WP Umbrella are solid options for WordPress management, and each has its own unique strengths. It's worth taking the time to evaluate your needs and priorities carefully before making a decision, so that you can choose the tool that's right for you. --- # Manage WP vs Infinite WP: 40+ Feature Comparisons Source: https://adityaarsharma.com/manage-wp-vs-infinite-wp/ As a WordPress user and website owner, managing multiple sites can be a daunting task. That's why I decided to compare two popular WordPress site management tools: Manage WP and Infinite WP. Both tools offer a range of features to help you manage and maintain your WordPress sites. In this post, I'll be sharing my experience after comparing 40 features of these tools. Whether you're a blogger, marketer, or developer, this SEO-optimized comparison will help you choose the best tool for your needs. So, let's dive in and see how these two products stack up against each other. ## What is Manage WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/managewp-dashboad.jpg) ManageWP is a powerful WordPress management tool that allows you to manage multiple WordPress websites from a single dashboard. With ManageWP, you can easily update plugins, themes, and WordPress core across all your sites with just a few clicks. You can also schedule backups, monitor uptime, and track website performance metrics all from one place. This tool is especially useful for website owners who manage multiple websites or for agencies that handle website maintenance for clients. With ManageWP, you can streamline your workflow and save time, so you can focus on growing your business. ## What is Infinite WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Infinite-WP-Dashboard.jpg) Infinite WP is a powerful WordPress management tool that allows users to manage multiple WordPress sites from one central location. This tool is incredibly useful for bloggers, web developers, and anyone who manages multiple WordPress sites. With Infinite WP, you can perform updates, backups, and security checks on all your sites with just a few clicks. This tool also allows you to monitor your site's performance and receive notifications if anything goes wrong. Overall, Infinite WP is an essential tool for anyone who wants to streamline their WordPress management process and ensure their sites are always up-to-date and secure. ## Comparison Table Between Manage WP vs Infinite WP: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between Manage WP and Infinite WP? When it comes to choosing between Manage WP and Infinite WP, there are a few factors to consider. Firstly, if you're looking for a more affordable option, Manage WP may be the way to go. It offers a range of features at a lower price point than Infinite WP. On the other hand, if you're looking for more advanced features and customization options, Infinite WP may be the better choice. It offers more flexibility and control over your WordPress sites, allowing you to manage them in a more granular way. Ultimately, the choice between these two tools will depend on your specific needs and budget. If you're looking for a simple and affordable solution, Manage WP may be the better option. But if you need more advanced features and are willing to pay a higher price, Infinite WP may be the way to go. Regardless of which tool you choose, both Manage WP and Infinite WP are excellent options for managing your WordPress sites. They offer a range of features and benefits that can help streamline your workflow and make managing your sites easier and more efficient. So, take some time to consider your needs and budget, and choose the tool that's right for you! --- # Manage WP vs Ithemes Sync: 40+ Feature Comparisons Source: https://adityaarsharma.com/manage-wp-vs-ithemes-sync/ Managing multiple websites can be a daunting task. That's why I decided to compare two of the most popular WordPress site management tools - Manage WP and Ithemes Sync. After thorough research and testing, I've come up with a list of 40 feature comparisons to help you decide which tool is best for your needs. Whether you're a beginner or an experienced user, this comparison will give you a comprehensive insight into the capabilities of these two tools. So, let's dive in and see which one comes out on top! ## What is Manage WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/managewp-dashboad.jpg) ManageWP is a powerful WordPress management tool that allows you to manage multiple WordPress websites from a single dashboard. With ManageWP, you can easily update plugins, themes, and WordPress core across all your sites with just a few clicks. You can also schedule backups, monitor uptime, and track website performance metrics all from one place. This tool is especially useful for website owners who manage multiple websites or for agencies that handle website maintenance for clients. With ManageWP, you can streamline your workflow and save time, so you can focus on growing your business. ## What is Ithemes Sync ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Itheme-Sync-Dashboard.gif) If you're looking for an efficient way to manage multiple WordPress sites, then iThemes Sync might just be the tool for you. This powerful WordPress management tool allows you to easily manage all of your WordPress sites from a single dashboard, saving you time and hassle. With iThemes Sync, you can update themes and plugins across multiple sites with just a few clicks, and even schedule backups and security scans to keep your sites safe and secure. Whether you're a blogger, developer, or business owner, iThemes Sync is a must-have tool for anyone who wants to simplify their WordPress management tasks and focus on what really matters - creating great content and growing their online presence. ## Comparison Table Between Manage WP vs Ithemes Sync: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between Manage WP and Ithemes Sync? When it comes to choosing between Manage WP and iThemes Sync, it ultimately depends on your specific needs and preferences. However, based on their features and capabilities, I would recommend Manage WP for most users. One of the key advantages of Manage WP is its user-friendly interface, which allows you to manage multiple WordPress sites from a single dashboard. It also offers a range of useful features, such as automated backups, security scans, and performance optimization tools. On the other hand, iThemes Sync is a more advanced tool that offers additional features such as uptime monitoring, SEO reporting, and client reporting. It also provides more granular control over your WordPress sites and allows you to manage plugins and themes more easily. Overall, if you're looking for a simple and easy-to-use WordPress management tool, Manage WP is a great choice. However, if you need more advanced features and greater control over your sites, iThemes Sync may be the better option. --- # Manage WP vs Updraft Central: 40+ Feature Comparisons Source: https://adityaarsharma.com/manage-wp-vs-updraft-central/ As a WordPress site owner, managing multiple websites can be a daunting task. That's why I decided to compare two popular site management tools - Manage WP and Updraft Central. Both of these products offer a wide range of features to help you manage your WordPress sites efficiently. In this blog post, I'll be sharing my experience after comparing 40 essential features of these tools. By the end of this article, you'll have a clear understanding of which tool is best suited for your site management needs. So, let's dive in and compare Manage WP vs Updraft Central! ## What is Manage WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/managewp-dashboad.jpg) ManageWP is a powerful WordPress management tool that allows you to manage multiple WordPress websites from a single dashboard. With ManageWP, you can easily update plugins, themes, and WordPress core across all your sites with just a few clicks. You can also schedule backups, monitor uptime, and track website performance metrics all from one place. This tool is especially useful for website owners who manage multiple websites or for agencies that handle website maintenance for clients. With ManageWP, you can streamline your workflow and save time, so you can focus on growing your business. ## What is Updraft Central ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Updraft-Central-Dashboard.jpg) Updraft Central is a powerful WordPress management tool that allows website owners and developers to easily manage and monitor multiple WordPress sites from a single dashboard. With Updraft Central, you can perform a range of tasks, including updating plugins and themes, creating backups, and monitoring site performance. One of the most impressive features of Updraft Central is its ability to manage multiple WordPress sites simultaneously. This can save you a lot of time and effort, especially if you manage several sites. You can easily perform updates, backups, and other tasks across all your sites with just a few clicks. Another great feature is the ability to create custom groups of sites. This is particularly useful if you have a large number of sites to manage, as it allows you to organize them into logical groups based on factors such as client, website type, or geographic location. Overall, Updraft Central is an essential tool for anyone who wants to streamline their WordPress management process. Its intuitive interface and powerful features make it a must-have for website owners and developers alike. ## Comparison Table Between Manage WP vs Updraft Central: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between Manage WP and Updraft Central? After thoroughly researching and comparing the features of Manage WP and Updraft Central, we recommend choosing Manage WP as your go-to WordPress management tool. While both tools offer similar features such as website monitoring, backups, and updates, Manage WP stands out for its user-friendly interface, extensive reporting and analytics, and impressive integration capabilities. Manage WP also offers a variety of add-ons and integrations, such as Google Analytics, SEO optimization, and white-labeling options, making it a versatile and customizable option for managing multiple WordPress sites. Overall, Manage WP is a reliable and efficient tool for managing WordPress sites, and its comprehensive features and ease of use make it the top choice for website owners and managers. --- # Manage WP vs WPMU Dev Hub: 40+ Feature Comparisons Source: https://adityaarsharma.com/manage-wp-vs-wpmu-dev-hub/ As a WordPress user, managing multiple sites can be a daunting task. That's where site management tools like Manage WP and WPMU Dev Hub come in handy. I recently compared the two to see which one offers more features and better value for money. In this post, I'll share my experience and compare 40 features of these WordPress site management tools so you can decide which one is right for you. So, let's dive in! ## What is Manage WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/managewp-dashboad.jpg) ManageWP is a powerful WordPress management tool that allows you to manage multiple WordPress websites from a single dashboard. With ManageWP, you can easily update plugins, themes, and WordPress core across all your sites with just a few clicks. You can also schedule backups, monitor uptime, and track website performance metrics all from one place. This tool is especially useful for website owners who manage multiple websites or for agencies that handle website maintenance for clients. With ManageWP, you can streamline your workflow and save time, so you can focus on growing your business. ## What is WPMU Dev Hub ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/WP-Mu-Dev-Hub.jpg) WPMU Dev Hub is a powerful WordPress management tool that can help you streamline your website management tasks. With WPMU Dev Hub, you can easily manage multiple WordPress sites from a single dashboard, saving you time and effort. This tool offers a range of features including site backups, security scans, performance optimization, and more. Whether you're a blogger, marketer, or website owner, WPMU Dev Hub can help you keep your WordPress sites running smoothly and efficiently. So, if you're looking for a comprehensive WordPress management solution, WPMU Dev Hub is definitely worth checking out. ## Comparison Table Between Manage WP vs WPMU Dev Hub: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between Manage WP and WPMU Dev Hub? When it comes to choosing between Manage WP and WPMU Dev Hub, both products have their own unique strengths and weaknesses. Ultimately, the decision of which one to choose will depend on your specific needs and preferences. That being said, if you're looking for a user-friendly and affordable WordPress management tool, Manage WP is a great option. It offers a wide range of features, including website monitoring, backup and restore, and client reporting. Additionally, its pricing plans are reasonable and offer great value for money. On the other hand, if you're looking for a more advanced and comprehensive WordPress management solution, WPMU Dev Hub is the way to go. It offers a suite of powerful tools, including website optimization, security, and performance monitoring. While it may be a bit more expensive than Manage WP, it's definitely worth the investment if you're serious about managing multiple WordPress sites. Ultimately, the choice between Manage WP and WPMU Dev Hub comes down to your specific needs and budget. Whichever one you choose, both products are excellent options for managing your WordPress sites efficiently and effectively. --- # WP Umbrella vs Infinite WP: 40+ Feature Comparisons Source: https://adityaarsharma.com/wp-umbrella-vs-infinite-wp/ Managing multiple sites can be a daunting task. That's why I decided to compare two popular site management tools: WP Umbrella and Infinite WP. In this post, I'll share my experience after comparing 40 features of these two products. Whether you're new to site management or looking to switch to a new tool, this comparison will help you make an informed decision. So, let's dive in and see how WP Umbrella and Infinite WP stack up against each other! ## What is WP Umbrella ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/wp-umbrealla-WordPress-website-manager-tool.png) WP Umbrella is a powerful WordPress management tool that helps website owners and developers to monitor and manage their WordPress websites with ease. This tool provides a wide range of features that allow users to keep track of their website's performance, security, and uptime. With WP Umbrella, users can monitor their website's uptime and receive instant alerts in case of any downtime. They can also track their website's performance metrics, such as page load times and server response times, and optimize their website accordingly. Moreover, WP Umbrella also provides advanced security features that help users to protect their website from malware attacks, brute force attacks, and other security threats. Overall, WP Umbrella is an all-in-one WordPress management tool that simplifies website management and helps users to keep their website secure, fast, and reliable. ## What is Infinite WP ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Infinite-WP-Dashboard.jpg) Infinite WP is a powerful WordPress management tool that allows users to manage multiple WordPress sites from one central location. This tool is incredibly useful for bloggers, web developers, and anyone who manages multiple WordPress sites. With Infinite WP, you can perform updates, backups, and security checks on all your sites with just a few clicks. This tool also allows you to monitor your site's performance and receive notifications if anything goes wrong. Overall, Infinite WP is an essential tool for anyone who wants to streamline their WordPress management process and ensure their sites are always up-to-date and secure. ## Comparison Table Between WP Umbrella vs Infinite WP: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between WP Umbrella and Infinite WP? When it comes to choosing between WP Umbrella and Infinite WP, it really depends on your specific needs and preferences. However, based on our research and analysis, we would recommend WP Umbrella for most users. WP Umbrella is a comprehensive WordPress management tool that offers a wide range of features, including website monitoring, security scanning, performance optimization, backup and restore, and more. It is user-friendly and easy to navigate, making it a great choice for both beginners and advanced users. On the other hand, Infinite WP is a powerful WordPress management tool that also offers a range of features, including website management, backup and restore, performance optimization, and more. However, it may be more suitable for advanced users who are looking for more customization options and control over their WordPress sites. Overall, both WP Umbrella and Infinite WP are great choices for WordPress management, but WP Umbrella's user-friendly interface and comprehensive features make it the better choice for most users. --- # WP Umbrella vs Ithemes Sync: 40+ Feature Comparisons Source: https://adityaarsharma.com/wp-umbrella-vs-ithemes-sync/ As a WordPress site manager, I am always on the lookout for the best tools to help me manage my sites efficiently. Recently, I had the opportunity to compare two of the most popular WordPress site management tools - WP Umbrella and iThemes Sync. After testing both tools extensively, I have compiled a list of 40 feature comparisons to help you decide which tool is the best fit for your needs. In this blog post, I will share my experience and insights to help you make an informed decision. So, let's dive in and compare WP Umbrella vs iThemes Sync! ## What is WP Umbrella ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/wp-umbrealla-WordPress-website-manager-tool.png) WP Umbrella is a powerful WordPress management tool that helps website owners and developers to monitor and manage their WordPress websites with ease. This tool provides a wide range of features that allow users to keep track of their website's performance, security, and uptime. With WP Umbrella, users can monitor their website's uptime and receive instant alerts in case of any downtime. They can also track their website's performance metrics, such as page load times and server response times, and optimize their website accordingly. Moreover, WP Umbrella also provides advanced security features that help users to protect their website from malware attacks, brute force attacks, and other security threats. Overall, WP Umbrella is an all-in-one WordPress management tool that simplifies website management and helps users to keep their website secure, fast, and reliable. ## What is Ithemes Sync ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Itheme-Sync-Dashboard.gif) If you're looking for an efficient way to manage multiple WordPress sites, then iThemes Sync might just be the tool for you. This powerful WordPress management tool allows you to easily manage all of your WordPress sites from a single dashboard, saving you time and hassle. With iThemes Sync, you can update themes and plugins across multiple sites with just a few clicks, and even schedule backups and security scans to keep your sites safe and secure. Whether you're a blogger, developer, or business owner, iThemes Sync is a must-have tool for anyone who wants to simplify their WordPress management tasks and focus on what really matters - creating great content and growing their online presence. ## Comparison Table Between WP Umbrella vs Ithemes Sync: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between WP Umbrella and Ithemes Sync? After reviewing the features and benefits of both WP Umbrella and iThemes Sync, it's clear that both tools have a lot to offer WordPress site owners and managers. However, depending on your specific needs and priorities, one tool may be a better fit for you than the other. If you're looking for a comprehensive solution that covers everything from security and performance monitoring to backup and update management, WP Umbrella is an excellent choice. With its intuitive dashboard, customizable alerts, and powerful automation features, it can help you stay on top of your WordPress site's health and performance with ease. On the other hand, if you're primarily interested in streamlining your workflow and managing multiple sites efficiently, iThemes Sync may be the better option. Its centralized dashboard and bulk update capabilities make it easy to manage all your WordPress sites from one place, saving you time and effort. Ultimately, the choice between WP Umbrella and iThemes Sync comes down to your individual needs and preferences. Consider what features are most important to you, and try out both tools to see which one works best for your WordPress management needs. --- # WP Umbrella vs Updraft Central: 40+ Feature Comparisons Source: https://adityaarsharma.com/wp-umbrella-vs-updraft-central/ As a WordPress site owner, managing multiple websites can be a daunting task. It requires a lot of time and effort to keep everything up-to-date and running smoothly. That's why I decided to compare two popular WordPress site management tools: [WP Umbrella](https://adityaarsharma.com/go/updraft-central) and Updraft Central. In this blog, I will share my experience after comparing the features of these tools. With over 40 feature comparisons, this article will help you determine which tool is the best fit for your WordPress site management needs. So, let's dive in and see how WP Umbrella and Updraft Central stack up against each other. ## What is WP Umbrella ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/wp-umbrealla-WordPress-website-manager-tool.png) WP Umbrella is a powerful WordPress management tool that helps website owners and developers to monitor and manage their WordPress websites with ease. This tool provides a wide range of features that allow users to keep track of their website's performance, security, and uptime. With WP Umbrella, users can monitor their website's uptime and receive instant alerts in case of any downtime. They can also track their website's performance metrics, such as page load times and server response times, and optimize their website accordingly. Moreover, WP Umbrella also provides advanced security features that help users to protect their website from malware attacks, brute force attacks, and other security threats. Overall, WP Umbrella is an all-in-one WordPress management tool that simplifies website management and helps users to keep their website secure, fast, and reliable. ## What is Updraft Central ? ![](https://adityaarsharma.com/wp-content/uploads/2023/04/Updraft-Central-Dashboard.jpg) Updraft Central is a powerful WordPress management tool that allows website owners and developers to easily manage and monitor multiple WordPress sites from a single dashboard. With Updraft Central, you can perform a range of tasks, including updating plugins and themes, creating backups, and monitoring site performance. One of the most impressive features of Updraft Central is its ability to manage multiple WordPress sites simultaneously. This can save you a lot of time and effort, especially if you manage several sites. You can easily perform updates, backups, and other tasks across all your sites with just a few clicks. Another great feature is the ability to create custom groups of sites. This is particularly useful if you have a large number of sites to manage, as it allows you to organize them into logical groups based on factors such as client, website type, or geographic location. Overall, Updraft Central is an essential tool for anyone who wants to streamline their WordPress management process. Its intuitive interface and powerful features make it a must-have for website owners and developers alike. ## Comparison Table Between WP Umbrella vs Updraft Central: 40+ Feature Comparisons I have compared all the popular Best 8 WordPress Management Tools and created [a comprehensive comparison table which includes - ManageWP vs MainWP vs WP Umbrella vs Infinite WP vs WPMU Dev HUB vs iThemes Sync vs Updraft Central vs WP Remote](https://adityaarsharma.com/best-8-wordpress-management-tools/) to help you make an informed decision.  ## Buyer's Guide: Which WordPress Management Tool Should you Choose between WP Umbrella and Updraft Central? When it comes to choosing between WP Umbrella and Updraft Central for managing your WordPress sites, it ultimately depends on your specific needs and preferences. However, based on our research and analysis, we would recommend WP Umbrella as the better option for most users. One of the main advantages of WP Umbrella is its all-in-one approach to WordPress management. It offers a comprehensive set of features, including uptime monitoring, security scans, performance optimization, and more, all within a single platform. This can save you time and money compared to using multiple tools or services. Another key benefit of WP Umbrella is its user-friendly interface and intuitive dashboard. Even if you're not a tech expert, you can easily navigate the platform and access the information you need to manage your WordPress sites effectively. In contrast, Updraft Central is a more specialized tool that focuses primarily on backup and restoration. While it does offer some additional features like site cloning and updates management, it may not be the best choice if you need a more comprehensive solution for WordPress management. Of course, every user has different needs and priorities, so we encourage you to evaluate both WP Umbrella and Updraft Central based on your specific requirements. Ultimately, the right choice will depend on factors like your budget, the number of sites you need to manage, and the features you need to keep your WordPress sites secure and running smoothly. --- Generated from RankReady