DEV Community

saga su
saga su

Posted on AI-assisted

Running a job board on Cloudflare Workers' free plan: what broke and how I fixed it

Running a job board on Cloudflare Workers' free plan: what broke and how I fixed it

I run DMtheFounder, a job board for early engineering roles that startup founders post themselves on X and Hacker News. It's a one-person project, so I built it on the Cloudflare Workers free plan: TanStack Start for the app, D1 (SQLite) for data, and Workers for everything else. Global, fast, and $0 a month.

That worked until it didn't. Here are the three walls I hit, in order, and what got me past each one.

Wall 1: Error 1102, or "10ms is not a lot of CPU"

One evening I was replying to people on X with links to the site, and a few clicks later the site started showing Error 1102: Worker exceeded resource limits.

The free plan gives each request about 10ms of CPU time. The metrics page told the story: CPU time per request was P50 54ms, P90 206ms, P99 437ms. Server-rendering a React page with a few hundred job cards and some counts simply doesn't fit in 10ms. Most requests got through on burst allowance; under a small spike, they didn't.

Upgrading to the $5 paid plan would have fixed it in a minute. I wanted to see how far I could get first.

Fix 1: cache whole pages at the edge for signed-out visitors. This was the big one. Almost everyone on a job board is anonymous, and they all see the same page. So in the Worker's fetch handler:

  • If the request has no session cookie and no Authorization header, look it up in caches.default first.

  • On a miss, render as usual, then store a copy for 5 minutes.

  • The cache key is the URL with tracking parameters (utm_*) stripped and the rest sorted, plus the current deployment's version ID, so every deploy starts with a clean cache.

  • Signed-in users and admin pages never touch the cache.

const cacheKey = isEdgeCacheable(request)
  ? edgeCacheKey(request, env.CF_VERSION_METADATA.id)
  : null;
if (cacheKey) {
  const hit = await caches.default.match(cacheKey);
  if (hit) return hit; // ~1ms instead of ~50ms
}
const response = await render(request);
if (cacheKey && isStorable(response)) {
  ctx.waitUntil(caches.default.put(cacheKey, forEdge(response.clone())));
}
Enter fullscreen mode Exit fullscreen mode

The version ID comes from the version_metadata binding in wrangler.jsonc. A cache hit costs about a millisecond of CPU instead of fifty.

Fix 2: render and query less. The home page used to render every live role. Now it renders 60 with a "Show more" link (?n=120). The per-category counts used by the landing pages are memoized per isolate for five minutes instead of being recomputed on every request.

What about the CPU numbers after both changes? Honestly, I don't have them yet. These changes went out recently, and there isn't a full week of post-change metrics to quote. I'd rather not publish a number I can't back up, so I'll update this post with the new P50 and P90 once the data is in.

Wall 2: D1 allows 50 queries per request

A week later an admin button ("fill empty fields from research") started failing. The SQL was fine. The problem was the count: it ran up to eight small UPDATEs per job, one after another, and the free plan allows 50 D1 queries per Worker invocation (1,000 on paid). Past about six jobs, it died.

The fix is the one the D1 docs recommend: read once, decide in JavaScript, write in batches. One SELECT pulls every listing and its current values; only the columns that are actually empty get an UPDATE; the updates go out through db.batch() in chunks of 50. A few hundred jobs now cost a handful of queries.

Related trap: a single D1 statement can bind at most 100 parameters. An inArray() over 150 IDs fails. Chunk it, or do the matching in JS.

Wall 3: IndexNow kept returning 429

I wanted Bing to pick up new roles quickly, so the site pinged IndexNow whenever a job went live. Every request came back 429 Too Many Requests.

IndexNow rate-limits by source IP, and Workers' outbound requests leave from IP addresses shared with a huge number of other sites. My few pings a day were sharing a bucket with everyone else's.

I already had a small VPS running the daily data pipeline, so the push moved there: a cron job reads the sitemap, compares it with what it sent last time, and submits only new, changed, or removed URLs. First run: 447 URLs, HTTP 200. The same script pattern now also notifies Google's Indexing API for job pages.

Lesson: when something rate-limits by IP, the Workers egress IP is not really yours.

Bonus: let the edge drop junk traffic for free

Workers metrics also showed a steady trickle of GET /wp-admin/install.php and friends. The site isn't WordPress, but each of those requests still ran the Worker and rendered a 404.

One WAF custom rule (the free plan includes five) blocks them before they reach the Worker:

(http.request.uri.path contains "/wp-")
or (ends_with(http.request.uri.path, ".php"))
or (starts_with(http.request.uri.path, "/.env"))
or (starts_with(http.request.uri.path, "/.git"))
Enter fullscreen mode Exit fullscreen mode

Action: Block. Zero CPU for scanners.

Where this leaves me

The site still runs on the free plan. What made the difference, roughly in order of impact:

  1. Edge-cache anonymous pages, keyed by deployment version.

  2. Batch D1 writes; read once, write only what changed.

  3. Don't render what nobody scrolls to.

  4. Move anything IP-sensitive off Workers.

  5. Block junk at the edge.

I'll upgrade the day signed-in traffic grows, because those requests can't be cached. At $5 a month that's an easy call. But it was worth learning where the limits are first.

If you're an engineer who'd rather message a founder than fill in an application form, the roles are at dmthefounder.com. Founders can post for free. Happy to answer questions about the setup in the comments.

— Sagasu, building DMtheFounder

Top comments (0)