The Server That Couldn't Keep Up With Itself
About eight months ago I was maintaining a mid-size membership portal for a local professional association — roughly 3,000 registered users, a public-facing directory, member dashboards, and a news section that got updated maybe twice a week. Nothing exotic. The whole thing ran on a shared cPanel hosting account, PHP 8.1, MySQL 5.7, the usual setup you get from an Indonesian hosting provider for a few hundred thousand rupiah a month.
Then the client ran a member drive. Signups doubled in six weeks. Traffic on the public directory page climbed from maybe 200 visits a day to closer to 900. And suddenly the site started hitting 504 Gateway Timeout errors during peak afternoon hours. The hosting provider sent an automated warning about CPU usage. The client forwarded it to me at 10 PM on a Friday. Fun.
I pulled the slow query log first — a reflex at this point — and the queries themselves were fine. I'd already applied composite indexes on the directory search columns (if you haven't done that work yet, here's how dramatically indexing alone can move the needle). The problem wasn't query speed. The problem was query volume. The same expensive aggregation queries were executing on every single page load, from every visitor, with zero reuse. The application had no caching layer at all. Every request was hitting the database from scratch.
This post is the before and after of fixing that — specifically within the constraints of shared cPanel hosting, where you don't have Redis, Memcached, Varnish, or any of the tools that come up first when you Google "PHP caching."
Before: What the Application Was Actually Doing
To understand the fix I need to show you the problem clearly. The public member directory page was doing four separate queries on every load:
- Fetch all active members with their specialisation tags (LEFT JOIN across three tables, ~3,200 rows)
- Aggregate a count of members per specialisation for the filter sidebar
- Fetch the five most recently joined members for a "New Members" widget
- Fetch the three latest news posts for a sidebar widget
Queries two, three, and four were almost entirely static. The specialisation counts changed only when someone's membership status changed — a few times a day at most. The latest news posts changed twice a week. But they were being recalculated on every single request.
The directory search itself was the one query that genuinely needed to be dynamic per request. Everything else was pure waste.
Here's a simplified version of what the page bootstrap looked like before:
<?php
// Before — no caching, all queries run on every request
$members = $pdo->query(
"SELECT m.*, GROUP_CONCAT(t.name) as tags
FROM members m
LEFT JOIN member_tags mt ON mt.member_id = m.id
LEFT JOIN tags t ON t.id = mt.tag_id
WHERE m.status = 'active'
GROUP BY m.id
ORDER BY m.last_name ASC"
)->fetchAll(PDO::FETCH_ASSOC);
$tag_counts = $pdo->query(
"SELECT t.name, COUNT(mt.member_id) as total
FROM tags t
LEFT JOIN member_tags mt ON mt.tag_id = t.id
LEFT JOIN members m ON m.id = mt.member_id AND m.status = 'active'
GROUP BY t.id
ORDER BY total DESC"
)->fetchAll(PDO::FETCH_ASSOC);
$recent_members = $pdo->query(
"SELECT id, first_name, last_name, joined_at
FROM members
WHERE status = 'active'
ORDER BY joined_at DESC
LIMIT 5"
)->fetchAll(PDO::FETCH_ASSOC);
$latest_news = $pdo->query(
"SELECT id, title, slug, published_at
FROM news
WHERE status = 'published'
ORDER BY published_at DESC
LIMIT 3"
)->fetchAll(PDO::FETCH_ASSOC);
Four queries, zero cache. On a quiet day this was fine. Under load it was a slow-motion disaster.
The Caching Options Available on Shared Hosting
Before writing a single line of fix code, I mapped out what I actually had available. On a standard cPanel shared account, your realistic options are:
- File-based caching — serialize PHP data or write JSON to a file, check
filemtime()to validate TTL. Zero dependencies, works everywhere. - PHP APCu — an in-memory key-value store. Some hosts enable it, many don't. Mine didn't.
- MySQL as a cache table — you can store rendered fragments or serialized query results in a cache table. Sounds circular but works for very specific cases.
- Output buffering to flat HTML files — capture the rendered HTML of entire pages and serve the static file on subsequent requests.
I ended up using file-based caching for data-layer results and output buffering for the full directory page. Both are pure PHP, no host configuration changes needed.
After: The File-Based Data Cache
I built a small cache helper — nothing clever, about 40 lines — that wraps get and set operations around a /cache/ directory I created one level above the web root so it's not directly accessible via URL.
<?php
// cache_helper.php
function cache_get(string $key): mixed {
$file = CACHE_DIR . '/' . md5($key) . '.cache';
if (!file_exists($file)) return null;
$data = unserialize(file_get_contents($file));
if ($data['expires'] < time()) {
unlink($file);
return null;
}
return $data['value'];
}
function cache_set(string $key, mixed $value, int $ttl = 300): void {
$file = CACHE_DIR . '/' . md5($key) . '.cache';
$data = ['expires' => time() + $ttl, 'value' => $value];
file_put_contents($file, serialize($data), LOCK_EX);
}
function cache_bust(string $key): void {
$file = CACHE_DIR . '/' . md5($key) . '.cache';
if (file_exists($file)) unlink($file);
}
The LOCK_EX flag on file_put_contents is important — without it, concurrent writes under load can corrupt the cache file. I learned that the hard way on a staging environment stress test before deploying.
Then I rewrote the directory bootstrap to use it:
<?php
// After — selective caching per data type
$tag_counts = cache_get('tag_counts');
if ($tag_counts === null) {
$tag_counts = $pdo->query(
"SELECT t.name, COUNT(mt.member_id) as total
FROM tags t
LEFT JOIN member_tags mt ON mt.tag_id = t.id
LEFT JOIN members m ON m.id = mt.member_id AND m.status = 'active'
GROUP BY t.id
ORDER BY total DESC"
)->fetchAll(PDO::FETCH_ASSOC);
cache_set('tag_counts', $tag_counts, 1800); // 30 minutes
}
$recent_members = cache_get('recent_members');
if ($recent_members === null) {
$recent_members = $pdo->query(
"SELECT id, first_name, last_name, joined_at
FROM members
WHERE status = 'active'
ORDER BY joined_at DESC
LIMIT 5"
)->fetchAll(PDO::FETCH_ASSOC);
cache_set('recent_members', $recent_members, 600); // 10 minutes
}
$latest_news = cache_get('latest_news');
if ($latest_news === null) {
$latest_news = $pdo->query(
"SELECT id, title, slug, published_at
FROM news
WHERE status = 'published'
ORDER BY published_at DESC
LIMIT 3"
)->fetchAll(PDO::FETCH_ASSOC);
cache_set('latest_news', $latest_news, 3600); // 1 hour
}
// Main member list stays uncached — it's dynamic per search filters
// But we DO cache the unfiltered default view separately
$members = cache_get('members_default_view');
if ($members === null) {
$members = $pdo->query(/* ... full query ... */)->fetchAll(PDO::FETCH_ASSOC);
cache_set('members_default_view', $members, 300); // 5 minutes
}
The TTL decisions were deliberate. News: 1 hour because it's updated editorially and staleness barely matters. Tag counts: 30 minutes because they shift slowly. Recent members: 10 minutes because new signups would feel bad if they didn't see themselves appear within a reasonable window. Default member list: 5 minutes, shorter because it's the primary content.
Cache Invalidation: The Part Everyone Underestimates
Setting TTLs is the easy part. The harder question is: what happens when an admin updates something and the cache is now stale?
For this project I added explicit cache_bust() calls in the admin panel actions. When a news post is published, cache_bust('latest_news') fires immediately. When a member's status is changed, cache_bust('members_default_view') and cache_bust('recent_members') both fire. The logic lives inside the same controller functions that handle the DB writes, so it's impossible to update the database without also busting the relevant cache keys.
This is a pattern I trust more than relying purely on TTL expiry, especially for admin-driven content. TTL is your safety net. Explicit busting is your first line of defence.
The Output Buffer Layer for the Full Directory Page
Even with data caching in place, PHP was still bootstrapping, running the cache-check logic, building the template, and rendering HTML on every request. For the high-traffic public directory page, I added a second layer: full page output caching.
<?php
// At the very top of directory.php, before anything else
define('PAGE_CACHE_FILE', CACHE_DIR . '/page_directory.html');
define('PAGE_CACHE_TTL', 120); // 2 minutes
if (
file_exists(PAGE_CACHE_FILE) &&
(time() - filemtime(PAGE_CACHE_FILE)) < PAGE_CACHE_TTL &&
empty($_GET) // don't cache search/filter requests
) {
readfile(PAGE_CACHE_FILE);
exit;
}
ob_start();
// ... all normal page logic runs here ...
$html = ob_get_clean();
file_put_contents(PAGE_CACHE_FILE, $html, LOCK_EX);
echo $html;
The empty($_GET) check is critical. If someone has applied search filters, those results are dynamic and must not be cached to the shared page file. Only the default unfiltered directory view gets the full-page cache treatment.
One gotcha I hit: I had a session-based flash message system that was rendering inside the page layout. The output buffer was capturing those messages and storing them in the HTML file, so they'd show up for every visitor until the cache expired. I had to move flash message rendering outside the buffered section — they now render after readfile() via a small JS injection. It's a slightly awkward solve but it works cleanly in practice.
Results: What Actually Changed
I measured before and after using New Relic Lite (free tier, available as a cPanel plugin from some hosts) and manual timing with PHP's microtime() at the page level.
- Average server response time for the directory page: 1,340ms ? 95ms (cached hits), 680ms (cache miss, data layer still warm)
- MySQL query count per directory page load: 4 queries ? 0–1 queries (zero on full-page cache hit, one dynamic query on filter requests)
- CPU usage warnings from host: stopped entirely within 48 hours of deploying
- 504 errors: zero since deployment, including during a subsequent member announcement that drove a spike of ~400 concurrent visitors
The "60% server load reduction" in the title is what the hosting panel's resource usage graph showed over a 7-day comparison window. Your numbers will vary depending on your query complexity and traffic shape, but the principle is consistent: stop doing the same expensive work repeatedly when the output doesn't change.
What I'd Do Differently Next Time
A few things I'd refine if building this from scratch today:
- Use a cache key registry. Right now cache keys are hardcoded strings scattered across files. A central constants file or enum would make cache_bust operations safer and easier to audit.
- Add a stampede guard. Under heavy load, when a cache file expires, multiple simultaneous requests can all find the cache empty and all regenerate concurrently — the "cache stampede" problem. A simple lock file approach can prevent this.
- Version the cache keys on deploy. I've shipped a code change that altered query output, forgotten to manually bust the cache, and had the old structure served for two minutes until TTL expired. Including a deploy timestamp in the key prefix solves this automatically.
The Broader Lesson
The instinct when a shared hosting site goes slow is often to upgrade the plan. Sometimes that's the right call. But in this case the site wasn't CPU-bound because the server was underpowered — it was CPU-bound because the application was doing unnecessary work on every request. Throwing more server at an inefficient application just delays the same cliff.
File-based caching on cPanel isn't glamorous. It doesn't show up on architecture diagrams. But it's stable, zero-dependency, and genuinely effective. I've used the same pattern on four separate projects now and it has never failed me in a way that a TTL expiry or a deliberate cache bust didn't handle cleanly.
If you're building or maintaining PHP applications on shared hosting and you haven't added a caching layer yet, this is the highest-leverage hour you'll spend on performance this year. The tools are already in your PHP installation. You just need to use them.
Frequently Asked Questions
Can you use file-based caching on shared cPanel hosting without installing anything?
Yes. PHP's native file system functions are all you need — you write serialized or JSON data to a cache directory outside the web root, then check file modification time before deciding whether to regenerate. No extensions, no root access, no PECL installs required.
How do I decide what cache TTL (time-to-live) to use for different data types?
It depends on how stale the data can safely be. Static reference data like country lists or category trees can tolerate 24 hours or more; user-facing counts like unread notifications should be 60 seconds at most. Start conservative and increase TTL only after you observe no correctness problems.
Does page-level output buffering work on cPanel shared hosting?
Yes, PHP's ob_start() and ob_get_clean() are available on virtually every shared host. The key constraint is that you must serve cached HTML before any session_start() or setcookie() calls, otherwise PHP throws headers-already-sent errors. Structure your bootstrap file carefully and it works reliably.