Arun Prakash · Reel 2026

· 15 min read · WordPress · Performance · PHP · Redis · Cloudflare · Case study

The Plugins Weren't the Problem: Speeding Up a 280,000-Story WordPress Site

How I made Pathanamthitta Media, a Malayalam news site on WordPress, fast and cheap to run: a move to one VPS, layered caching, a database cleanup, a Next.js detour, and the custom theme that fixed it.

Pathanamthitta Media is a Malayalam news site from Pathanamthitta, Kerala. It has published over 280,000 stories on WordPress. A local news site like this is read mostly on phones, often budget Android phones on mobile data, so every slow millisecond shows.

Over 2026 I rebuilt how the site is hosted, cached and rendered. This post is the full account: what I changed, in what order, what the numbers did, and the wrong turn I took on the way. The short version is in the title. I spent months assuming the plugins and the platform were the problem. On a closer look, most of the weight was the theme.

Before After
Hosting₹7,500/mo₹1,500/mo
Lighthouse4892–96
Page build825–945 ms235–290 ms
Builds over 1 s~28%<1%
Plugins26 (2025)12

The numbers come from Lighthouse runs on the live site, Cloudflare's performance metrics, the server itself and an old database backup, and I say which. Where I only had a target or an estimate, I say that too.

The one idea behind all of it

If I had to put everything I learned into one line, it's this: the way to make WordPress efficient is to stop it from running. Every request that PHP doesn't have to handle is faster than any request it handles well.

So the site is built in layers, and each one exists to keep a request away from the next:

  1. Cloudflare's edge caches full pages, HTML included, not just images and scripts.
  2. nginx's page cache on the server answers what Cloudflare doesn't have yet.
  3. PHP and WordPress only run when neither cache has the page.
  4. Redis holds WordPress's objects, so even a page build skips most database queries.
  5. MariaDB keeps most of the database in memory, so the queries that do run rarely touch the disk.

The rest of this post is how each of those layers got there.

Step one: get off the expensive host

In February the site was on a managed DigitalOcean server that cost about ₹7,500 a month. I moved it to a single VPS running Docker, which now costs about ₹1,500. I set up every part of it myself and tuned each setting for this site rather than taking defaults:

  • nginx in front, with its FastCGI page cache. Pages are kept for hours, and nginx serves a stale copy if PHP is slow or down.
  • PHP 8.4 FPM with OPcache, in its own container.
  • MariaDB, sized so most of the database lives in memory. Its buffer pool is the largest single use of RAM on the server, and over 99.99% of reads are answered from memory. I made that trade on purpose: memory is cheap next to disk reads on a database this size.
  • Redis as the WordPress object cache, through Redis Object Cache.
  • Cloudflare on the free plan in front of all of it, with cache rules kept as code: full pages at the edge, static assets for a year, uploads for weeks, and anything logged in or in preview bypassed.

A lot of people told me Docker would cost performance. On this server I haven't seen a difference I could measure, and having each service in its own container made every later change easier to make and to undo.

Two more decisions did most of the work of making a cheap server enough.

The photos left the server. The uploads folder was around 120 GB. I wrote a media-offload plugin, Nimbus, that copies the library to Cloudflare R2, rewrites the URLs in posts, image sizes and shortcodes, keeps a way back, and then removes the local copies. R2 has no egress fees, so the photos, which are the bulk of the bytes, cost almost nothing to serve. Nimbus later grew into its own open-source project.

Purging became exact. Caching full pages at the edge only works if an edited story disappears from the cache straight away. The old setup purged Cloudflare with a plugin and often missed edits to stories that were already published, so readers saw an old version. Cloudflare supports purging by cache tag, but none of the WordPress plugins I found used it, so I built one, Tag Purge. Every page is tagged with the posts, categories and authors on it. When a story changes, Tag Purge purges exactly those tags at Cloudflare and clears the same pages from nginx's cache on the server. With purging that precise, pages can be cached for hours without serving stale news.

WordPress cron also moved to the host's own cron, so scheduled jobs stopped running inside reader requests.

Step two: the database had been collecting junk for years

In April I ran a full audit of the server. The headline was memory: the database and the rest of the stack were competing for it, and the server was dipping into swap. I rebalanced it so MariaDB still holds most of the data in memory and PHP and Redis have room too.

The rest of that audit, and what I did about each item:

  • A slider plugin was scanning 483,057 rows to return 54. Smart Slider's lookup ran on every page and took about two seconds. An index added online, with no downtime, fixed the query. Later the slider went away entirely.
  • wp_postmeta was 3,450 MB. Over 300,000 rows were stale oEmbed caches, and almost 300,000 more were edit locks. Deleting them and rebuilding the table brought it to 2,283 MB.
  • Temporary tables were spilling to disk, because tmp_table_size was 16 MB. I raised it to 64 MB.
  • PHP workers never restarted, so any memory a plugin leaked stayed leaked. Each worker now recycles after a fixed number of requests.

The site got steadier. It did not get fast on a phone. That needed a different answer, and my first answer was the wrong one.

The detour: a headless Next.js frontend

My assumption at the time was that WordPress and its plugins were heavy, and that the cleanest fix was to stop rendering pages with WordPress at all. In March I built a headless Next.js frontend and ran it on a separate subdomain. WordPress became a content API over GraphQL, and Next.js rendered the pages with incremental static regeneration. It never replaced the main site.

It was fast. It was also a large thing to maintain: two repositories, two technologies and two deploys for one site. The GraphQL queries were expensive for WordPress to answer, and every regeneration fired bursts of them. The regeneration cache grew to 14 GB by April and needed a rotation script.

Caching the GraphQL layer was the obvious next step, and I went a long way down it. I built a custom system that cached individual entities in Redis, posts, terms and authors, each under its own key, and invalidated them when they changed. It worked, but every piece made the setup more complicated, and it started to feel like I was building a second CMS to work around the first.

It also answered the wrong question. When I went back and profiled the WordPress side properly, the plugins and the platform were mostly fine. The theme was the heavy part. Next.js was a reasonable solution, but it wasn't the first one I should have reached for. Rebuilding the theme was smaller, cheaper to run and kept one codebase, so I took the subdomain down and did that instead.

The lesson I took from it: measure the layer you plan to replace before you replace it.

Measuring the real starting point

In early October I ran a proper baseline: a mobile Lighthouse run on a live article page.

Metric (5 Oct 2026) Value
Performance score48
Largest Contentful Paint10.4 s
First Contentful Paint5.2 s
Time to Interactive12 s
Cumulative Layout Shift0.147
Page weight5.4 MB (3.9 MB of it photos), 40 scripts

The site ran Newspaper, a large commercial news theme, together with its page builder, its cloud library, its social counter and its standard pack, plus Smart Slider. Structured data came from three places at once, so every story had two breadcrumb trails and none was marked as a NewsArticle. A copy-protection plugin blocked text selection, which mostly hurt readers who wanted to share a line.

On a mid-range phone over mobile data, that meant downloading 5.4 MB and running 40 scripts to read one story.

Rebuilding the theme

I wrote a new classic WordPress theme from scratch, built around one rule: a story page has to work on a cheap phone before any JavaScript runs.

  • No page builder and no build step. Templates are plain PHP, with hand-written CSS and JavaScript.
  • CSS is inlined per page type. Each page gets only the stylesheets it needs, written into the HTML, so nothing blocks rendering. The full set is about 46 KB, under 10 KB compressed.
  • One deferred script. It is 15 KB minified, and every feature works without it: "load more" falls back to a link, search to the search page, and share to a WhatsApp link.
  • Images can't move the layout. Every photo sits in a frame with a fixed ratio, the lead image is fetched at high priority, and everything below it loads lazily.
  • WordPress defaults I didn't need are off, including emoji scripts, block-library CSS, embeds and the jQuery migrate shim.
  • SEO lives in the theme. There's one source of structured data with NewsArticle markup, and one breadcrumb trail.

The fonts needed the most care. All three are variable fonts, which ship every weight from thin to extra bold, and two of them also ship a width axis from condensed to expanded. The site uses a small slice of that, so I cut each font down to the slice:

Font What I removed Bytes
Anek Malayalam, headlinesWidth axis, weights under 600199,516 → 47,340
Anek Latin, EnglishWidth axis, weights under 400, unused characters and features95,572 → 32,216
Noto Sans Malayalam, bodyWeights under 400 and over 70089,468 → 46,488

I couldn't save much by removing characters from the Malayalam fonts. Malayalam joins letters into conjuncts, and each one needs its own glyph, so every glyph stayed and the savings came from the weight and width ranges. The body font also asks for the phone's own copy first, so on Android, which ships Noto Sans Malayalam, it isn't downloaded at all.

The theme went live on 17 October. Before switching, I checked what would change for the site: a handful of stories marked noindex in the old SEO plugin would have become indexable, which took a five-line fix. Rolling back was one command, and I never needed it.

Plain code, maintained with AI agents

A few years ago, plain PHP, CSS and JavaScript with no framework would have been a hard sell for a site this size. Frameworks and page builders exist because hand-written code is slow to change. AI coding agents changed that trade for me. With them, keeping a hand-written theme up to date is quick, so I can ship the lightest possible HTML and still move fast. This whole project leaned on them, from the theme and the plugins to the database audits and the server setup, with me deciding what to build and checking what they produced.

Removing 26 plugins

With the theme live, most plugins had nothing left to do. A week later I removed them in two batches, after backing each one up. I deleted the files directly, so their uninstall routines couldn't run.

  • Fourteen were dead weight: the four parts of the old theme's builder, Smart Slider, Query Monitor, two experimental performance plugins, Classic Widgets, the right-click blocker, an old push plugin built on an API Google has retired, and a few others. Five unused themes went with them.
  • Twelve had their jobs moved into the theme, among them ads (now a slot system in the theme), SEO, structured data, newsroom roles and the GraphQL server, which nothing needed once the headless frontend was gone.

A database backup from October 2025, before any of this work, lists 26 active plugins on the Newspaper theme, and more were added in the year after. Today 12 are active, and each does a job a theme shouldn't:

What changed

Cloudflare's performance metrics gave the cleanest comparison. I compared the week before the switch on the old theme with the days after it on the new one, leaving out the day of the switch.

From Cloudflare Old theme New theme
Average time to build a page825 to 945 ms235 to 290 ms
Pages over 1 secondabout 28%under 1%
Share answered by Cloudflare's cacheunchangedunchanged

The last row matters most. Cloudflare answered the same share of requests before and after, so caching didn't change. The 70% faster page builds came from the theme doing less work. The database agreed: slow queries dropped to about a third of what they were.

On 25 October I ran Lighthouse again on a live article, with the mobile preset and three runs per variant. The version that's live scored 96, 92 and 92, with a first paint of 1.7 s and about 80 ms of blocking time. That's against 48 three weeks earlier. These are lab scores, and I didn't record LCP or CLS on that run, so I can't give an after number for those yet.

Then the database again

With the old plugins gone, years of their leftovers were still in the database. On 24 October I cleaned it up, after taking backups:

  • 993,111 rows of post metadata left by SEO, theme, image and push plugins that were no longer installed.
  • 45,891 posts that weren't stories: 24,613 performance-plugin records and 21,260 old revisions.
  • 5,314 options from plugins long gone. The options table went from 6,189 rows to 989, and the options loaded on every request from 766 KB to 223 KB.
  • Three redundant indexes, dropped online in 2.5 seconds in total.

A smaller database also means more of it fits in memory, which is the whole point of the way MariaDB is set up.

Two slow paths needed their own fixes. The Nimbus offload queue query was examining 9.7 million rows and taking about 36 seconds. Rewritten with EXISTS, it returns the same IDs in 1.4 seconds. Site search scanned every story ever published, so I narrowed its default range to recent stories, which made it several times faster, with an option to search further back.

Planning for bots, not just readers

Once pages were cheap to build, a different kind of traffic stood out. Readers mostly hit cached pages. Crawlers and scrapers walk the long tail of old stories and listing pages. A news archive this size has to be built for both.

  • Expensive endpoints are cached and fenced. A scraper once worked through a "load more" button page by page. That endpoint's results are now cached in Redis for a few minutes, and a Cloudflare rule refuses calls from other sites. Server load from it dropped to almost nothing.
  • Sitemaps build one at a time. When a crawler requested many deep sitemap pages at once, I fixed the sitemap rather than chasing the crawler. Deep sitemap pages are cached for a day, and only one request at a time may build a page. The rest get a 503 with Retry-After and come back for the cached copy.
  • Crawlers that ignore the rules get blocked at the edge. A crawler that ignored robots.txt is now stopped by a Cloudflare rule before it reaches the server.

Two smaller problems weren't bots. Editors were seeing errors in the admin, which came from Cloudflare's Speed Brain prefetching, and turning it off cleared them. A burst of errors I first blamed on the database was Redis timing out. Raising its timeouts and letting pages fall back gracefully when Redis is slow fixed it.

The test I didn't plan: a story went viral

On 13 November one story went viral.

The server barely noticed. About 99% of requests for that story were answered by Cloudflare and never reached the server. Average server response stayed under 300 ms, with almost no errors. That is the layered cache doing its job: the page could be cached aggressively at the edge because any edit to it would purge it straight away.

Push notifications: fast is also about not annoying people

Push notifications moved into the theme with Firebase. At first every story was pushed, which was far too many.

Now editors can tick the stories that must go out, and the site decides for the rest. Each new story goes to Jev, a text classification model, which rates how important the story is and which topic it belongs to. That rating, recent reader interest in the topic, and whether the story is local make up a score, and only stories above a bar are pushed, with a daily limit and a cooldown between alerts. The same model suggests categories on the editor's write screen. The prompt to subscribe appears on a reader's second page view, not the first.

What's still open

  • Photos are still JPEG. A test on 160 recent photos showed AVIF at 960 px would be 87% smaller. I didn't ship it, because the free options would mean generating every size on this server, and the budget ruled out paid image resizing. It's the biggest win left.
  • I have lab scores, not field data, for the after state. The before numbers include LCP and CLS. The after run doesn't, and I'd like real-user Core Web Vitals to confirm the result.

What I'd tell myself in February

  • Keep PHP from running. The fastest WordPress request is the one WordPress never sees. Cache at the edge, cache on the server, and let PHP handle what's left.
  • Profile the theme first. On an old WordPress site it is the easiest thing to blame last and often the right thing to blame first.
  • Exact purging makes long caching safe. Cache tags let a news site cache for hours and still update a story the moment it's edited.
  • Plan for bots, not just readers. Readers hit the cache. Crawlers walk the whole archive.
  • A rewrite is the last tool. The Next.js frontend was fast, but it doubled the work. A better theme and a clean database got further for less.