My shared host gives me a web root, PHP, and a MySQL database. It does not give me cron. No shell, no crontab entry, no systemd timer, no background worker I can leave running. If you have ever tried to schedule anything on cPanel shared hosting, you know the menu of bad options:
- A control-panel cron UI that may or may not exist on your plan
- A free third-party service pinging a URL on a schedule (now your schedule depends on someone else’s uptime)
ignore_user_abort()hacks that die the moment the process manager reaps them- Or the classic: give up and run the job manually
I tried all of the workarounds over the years. What I ended up shipping instead is lazier and more reliable than any of them: let the site’s own traffic do the scheduling.
The idea in one paragraph
Every page request passes through some code that always runs - a footer include, a bootstrap, a front controller. At that point, ask the database one question: is there a job whose window is open? If yes, this request claims the window with a single conditional UPDATE and runs the job inline. If no, the request moves on - it costs one query. That is the whole pattern. I call it lazy-cron, and after using it in production for months, I generalized it into a single-file library: github.com/bryanhamiltondev/lazy-cron. One file, about 150 lines, PHP 7.1+, zero dependencies.
Where it came from: a scoreboard, not a thought experiment
This is not a pattern I designed on a whiteboard. The DJ Calendar runs a tour-prediction scoreboard: 61 rolling predictions about where DJs will play next, snapshotted every 6 hours so they can be graded later against what actually happened. The server has no cron. The snapshots run on first visit after each 6-hour window opens, archived straight into MySQL. It has worked that way since October 2026. The library is that on-visit snapshot logic, extracted, renamed, and hardened.
The hard part is not the schedule. It is the race.
Checking “is the window open?” with a SELECT is the obvious version of this pattern, and it is broken. SELECT-then-UPDATE has a gap between the two statements. Two visitors landing in the same millisecond both SELECT “open,” both UPDATE, both run the job. On a quiet site that might happen once a year - which is exactly when you will not be looking at it. On a busy site it happens the moment a bot crawler fires six parallel requests.
The fix is to make the check and the claim the same operation. lazy-cron’s claim is one conditional UPDATE:
UPDATE lazy_cron_locks
SET next_run = :now + :interval,
last_run = :now,
runs = runs + 1
WHERE name = :name
AND next_run <= :now
InnoDB takes a row lock on the matching row, and the next_run <= :now condition only lets the UPDATE through if the window is genuinely open. Then the result tells you which world you are in:
rowCount() == 1- this request owns the window. Run the job.rowCount() == 0- either the window is not open yet, or another concurrent request just claimed it. Either way: move on.
There is no race window because there is no gap. MySQL serializes the two requests at the row lock, and one of them simply loses. No file locks, no lock files left behind by a crashed process, no flock() semantics to reason about. If the library has one load-bearing line, it is that UPDATE - which is also why 150 lines is enough.
The API fits in a glance
require 'LazyCron.php';
LazyCron::boot($pdo); // a MySQL PDO handle
LazyCron::daily('db-backup', function () {
// first request after midnight runs this
});
LazyCron::hourly('sitemap-ping', function () { /* ... */ });
LazyCron::every(900, 'cache-warm', function () { /* ... */ }); // 15 min
Drop the calls wherever every request passes through. The name string is the lock row’s identity, so renaming a job and adding a job are the same operation: a new row.
Three behaviors I did not get right the first time
Worth calling out, because each one is the difference between a demo and production:
1. A throwing job must not brick its window. The first version marked the window done, then ran the job. If the job threw, the window was consumed and the work silently never happened. Now the window is only marked done inside the claim, and a job that throws reschedules itself 5 minutes out through an optional error hook - so a broken job retries with backoff instead of disappearing.
2. Missed windows must not queue up. If traffic stops for three days, a naive version runs the daily job three times on the next visitor. That is almost never what you want - three backups, three pings, three snapshot passes. lazy-cron self-heals: gaps in traffic collapse to one run, because the claim moves next_run forward from now, not from when it was due.
3. Latency is proportional to traffic - be honest about it. On a busy site, jobs run within milliseconds of their window. On an idle site, a daily job might run at 12:01 or at 4 in the afternoon when the first visitor shows up. If you need a job at exactly midnight, you need a real scheduler. But for backups, sitemap pings, cache warming, snapshotting, and digest generation, “sometime after the window opens” is the correct contract, and the honest version of that sentence is in the README.
Watch the lock hold
Reading about a row lock is not believing in it, so the repo ships with a browser playground. There is a “traffic surge” button that fires 6 concurrent requests at one due window. You can watch exactly one of them claim the job and the other five bounce off the locked row. It also simulates the deploy-worker pattern I use for patching this site, since the two solve the same problem from opposite ends: one makes work happen without a scheduler, the other makes changes land without a shell.
What this is not
- It is not a message queue. Jobs run inline in a request, so a job should take hundreds of milliseconds, not minutes.
- It is not sub-minute. The visitor is the tick, so intervals under a minute are meaningless.
- It is not for hosts where cron exists. If you have crontab, use crontab. This pattern earns its keep exactly where you have MySQL and a web root and nothing else.
The takeaway
“Shared hosting cannot run scheduled jobs” was never true. It cannot run daemons. But a conditional UPDATE turns every page request into a scheduler tick, and a row lock turns a crowd of visitors into exactly one worker. One file, ~150 lines, MIT licensed - read the whole thing before you trust it. You should; that is the point of shipping 150 lines instead of 1,500.