If you are deciding whether to bid on a project, the number that matters is how many people already have. That number has a half-life measured in hours, which makes it the one field in this dataset where cache age is a correctness issue rather than a cost trade-off.
The measurement
Across 401 projects, the median proposal count goes from about 1 proposal in the first hours after posting to about 10 by the twelfth hour.
Twelve hours is roughly the window in which a project goes from uncontested to crowded. That is faster than most people assume, and it is the reason the Actor's default cache age is 6 hours rather than a day.
Why a stale row is a wrong row
Most fields on a project do not move: the title, the budget, the skills and the country are the same tomorrow. total_bids is not — a row cached for 24 hours is describing a different competitive situation from the one on the site.
The failure is quiet. Your dataset shows a project with 2 proposals; you spend an hour writing a proposal; the project has 22. Nothing errored, and the number you acted on was real when it was fetched.
Normalising by age instead of comparing counts
Raw proposal counts are not comparable across projects of different ages, which is what posted_age_hours is for:
SELECT
slug, title, total_bids, posted_age_hours,
ROUND(total_bids::numeric / GREATEST(posted_age_hours, 1), 2) AS bids_per_hour,
CASE
WHEN posted_age_hours < 6 THEN 'fresh'
WHEN posted_age_hours < 24 THEN 'today'
ELSE 'older'
END AS age_band
FROM projects
WHERE data_age_hours < 2
ORDER BY bids_per_hour ASC;Sorting ascending by proposals per hour is the actual opportunity list: projects that have been up long enough to be real and have attracted less competition than their age would predict.
Why the default cache is 6 hours
It is the halfway point in the window where the number changes most. Shorter and most analytical runs pay for freshness they do not need; longer and a bidding decision is made on a number that has doubled.
Every row declares from_cache, fetched_at and data_age_hours regardless, so the choice is visible in the data — the general principle.
Running live
maxCacheAgeHours: 0 forces a fresh read of every row. It costs more in requests and wall-clock time, and for a bidding workflow it is the only correct setting.
For demand analysis — budgets by skill, subcategory mix, country comparisons — leave the default. None of those fields move within a day, and paying for freshness you do not use is the mirror image of the mistake above.
The opportunity this exposes
If competition arrives within hours, then latency is a competitive advantage on this platform — which is a slightly uncomfortable conclusion to publish, and it follows from the measurement.
The honest framing: a freelancer who sees new projects in their skill within the first hour is bidding against one or two people instead of ten. That is what the data says. Whether a market where speed beats fit is a good market is a different question, and not one the data can answer.


