Every hour, and the traffic jam at minute zero
0 * * * * /usr/local/bin/hourly.sh
That is the answer, and it is also the problem. Not because the expression is wrong, but because it is the same expression everybody else wrote.
Cron has no notion of spreading work out. It fires what it is told, when it is told. Every default in every panel, every example in every manual, and every generated configuration file puts a zero in the minute field, so the top of the hour is the second when the largest number of scheduled things on any given machine try to start at once.
Who else is standing there
On a server you look after, the minute-zero crowd is bigger than the jobs you wrote. Log rotation, package update checks, a backup agent, a security scanner, whatever the monitoring vendor installed, the hosting provider's own housekeeping, and the hourly job for every other site on the box.
On shared hosting, add the other tenants. You cannot see their crontabs and they cannot see yours, and all of you were given the same example in the same documentation. The node's disk queue at the top of the hour is a shared resource being contended for by people who have never met.
This produces three distinct costs, and they arrive in this order.
The job takes longer than it should. A task that runs in twenty seconds at :17 takes ninety at :00, because it is queueing behind everything else for disk and for database connections. Nothing fails, the duration just moves, and it moves in a way that never shows up when you test the job by hand in the middle of an afternoon.
Failures become correlated, which destroys the diagnosis. When six things break at the same instant every hour, each one looks like it caused the others. You will spend real time deciding whether the backup broke the database or the database broke the backup, and the answer is that both of them were fine and the machine was briefly out of memory. Staggering the schedule is worth doing for this reason alone: it turns one confusing incident into six independent events, five of which then stop happening.
External services see the spike, not the average. Forty client sites each making one API call an hour is a modest load spread out and forty simultaneous requests at the top of the hour otherwise. Rate limiters are almost always defined over a short window, so the average is irrelevant and the burst is the whole question. This is the mechanism behind an entire category of self-inflicted throttling: the 429 your own tooling produced.
Pick a minute, and pick it the same way every time
The fix is one character. The discipline is picking the character so that the same site always gets the same one, and so that a colleague can reproduce your choice without asking you.
Derive it from the domain:
printf '%s\n' $(( 16#$(printf %s example.com | md5sum | cut -c1-6) % 60 ))
That yields 45 for example.com. It yields the same 45 on any machine, next year, run by anyone. Put the resulting minute in the crontab and write the domain in a comment on the line above, so the next person can check the arithmetic rather than wonder whether the number means something.
# example.com, minute derived from the domain
45 * * * * /usr/local/bin/hourly.sh
Two properties are doing the work here. It is deterministic, so rebuilding a server from configuration puts the job back on the same minute rather than a new random one. And it is uncorrelated across sites, so forty client machines end up scattered across the hour without anyone maintaining a spreadsheet of who owns which minute.
Jenkins solved the same problem with an operator, H, which hashes the job name into the field. Plain cron has nothing equivalent, which is why this stays a convention you enforce in review rather than something the daemon does for you. The convention is cheap enough to be worth it: one comment line and a number.
When zero is actually the right answer
There are jobs whose correctness depends on the boundary, and moving them off it is a real bug rather than a tidy-up.
Anything that closes a period. Hourly billing, usage aggregation, a rate window that resets on the hour, a report defined as "sales between 14:00 and 15:00". For these the boundary is the specification.
The mistake is running them at the boundary. A job that starts at 15:00:00 and reports on the hour that just ended is racing the last few writes of that hour: a transaction committed at 14:59:59.8 may not be visible when the query runs, and it will not be included next hour either, because next hour's window starts at 15:00. Rows disappear from the total, once in a while, in a way that is almost impossible to reproduce.
Run it a few minutes after the boundary and make the window explicit in the query rather than implicit in the start time:
# reports on the hour that ended, not on "the last hour"
7 * * * * /usr/local/bin/close-hour.sh --hour "$(date -d '1 hour ago' +\%Y-\%m-\%dT\%H)"
Now the job's start time and the period it covers are two separate things, which means you can move the start time whenever you like, and re-run the job for a missed hour by passing the hour. Both of those turn out to matter the first time the server is down at the top of an hour.
Hourly, and whether it needs to be
Worth one paragraph, because it removes the problem entirely more often than people expect. A large share of hourly jobs are hourly because that felt about right, not because anything requires it. A cache warmer, a feed importer, a search index refresh: ask what actually breaks if it runs four times a day, and if the answer is nothing measurable, four times a day is a quarter of the load and a quarter of the failure surface.
Where the frequency genuinely is required, the next thing to check is whether the run still fits inside the interval, because an hourly job that has grown to fifty minutes is one bad day away from starting a second copy on top of the first: cron will not stop it, and the guard is one word on the line.