<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: saga su</title>
    <description>The latest articles on DEV Community by saga su (@saga_su_).</description>
    <link>https://dev.to/saga_su_</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4215241%2Fb717470c-3976-4773-b07b-17e1cf147856.jpg</url>
      <title>DEV Community: saga su</title>
      <link>https://dev.to/saga_su_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/saga_su_"/>
    <language>en</language>
    <item>
      <title>Running a job board on Cloudflare Workers' free plan: what broke and how I fixed it</title>
      <dc:creator>saga su</dc:creator>
      <pubDate>Thu, 29 Oct 2026 12:05:31 +0000</pubDate>
      <link>https://dev.to/saga_su_/running-a-job-board-on-cloudflare-workers-free-plan-what-broke-and-how-i-fixed-it-2hg9</link>
      <guid>https://dev.to/saga_su_/running-a-job-board-on-cloudflare-workers-free-plan-what-broke-and-how-i-fixed-it-2hg9</guid>
      <description>&lt;h1&gt;
  
  
  Running a job board on Cloudflare Workers' free plan: what broke and how I fixed it
&lt;/h1&gt;

&lt;p&gt;I run &lt;a href="https://dmthefounder.com" rel="noopener noreferrer"&gt;DMtheFounder&lt;/a&gt;, 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.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Wall 1: Error 1102, or "10ms is not a lot of CPU"
&lt;/h2&gt;

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

&lt;p&gt;The free plan gives each request about 10ms of CPU time. The metrics page told the story: CPU time per request was &lt;strong&gt;P50 54ms, P90 206ms, P99 437ms&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;Upgrading to the $5 paid plan would have fixed it in a minute. I wanted to see how far I could get first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 1: cache whole pages at the edge for signed-out visitors.&lt;/strong&gt; 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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;If the request has no session cookie and no &lt;code&gt;Authorization&lt;/code&gt; header, look it up in &lt;code&gt;caches.default&lt;/code&gt; first.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;On a miss, render as usual, then store a copy for 5 minutes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The cache key is the URL with tracking parameters (&lt;code&gt;utm_*&lt;/code&gt;) stripped and the rest sorted, plus the current deployment's version ID, so every deploy starts with a clean cache.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Signed-in users and admin pages never touch the cache.&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cacheKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isEdgeCacheable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;edgeCacheKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CF_VERSION_METADATA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ~1ms instead of ~50ms&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isStorable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;forEdge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;())));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The version ID comes from the &lt;code&gt;version_metadata&lt;/code&gt; binding in &lt;code&gt;wrangler.jsonc&lt;/code&gt;. A cache hit costs about a millisecond of CPU instead of fifty.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall 2: D1 allows 50 queries per request
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;UPDATE&lt;/code&gt;s per job, one after another, and the free plan allows &lt;strong&gt;50 D1 queries per Worker invocation&lt;/strong&gt; (1,000 on paid). Past about six jobs, it died.&lt;/p&gt;

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

&lt;p&gt;Related trap: &lt;strong&gt;a single D1 statement can bind at most 100 parameters&lt;/strong&gt;. An &lt;code&gt;inArray()&lt;/code&gt; over 150 IDs fails. Chunk it, or do the matching in JS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall 3: IndexNow kept returning 429
&lt;/h2&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Lesson: when something rate-limits by IP, the Workers egress IP is not really yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: let the edge drop junk traffic for free
&lt;/h2&gt;

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

&lt;p&gt;One WAF custom rule (the free plan includes five) blocks them before they reach the Worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(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"))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Action: Block. Zero CPU for scanners.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves me
&lt;/h2&gt;

&lt;p&gt;The site still runs on the free plan. What made the difference, roughly in order of impact:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Edge-cache anonymous pages, keyed by deployment version.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Batch D1 writes; read once, write only what changed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Don't render what nobody scrolls to.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Move anything IP-sensitive off Workers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Block junk at the edge.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you're an engineer who'd rather message a founder than fill in an application form, the roles are at &lt;a href="https://dmthefounder.com" rel="noopener noreferrer"&gt;dmthefounder.com&lt;/a&gt;. Founders can post for free. Happy to answer questions about the setup in the comments.&lt;/p&gt;

&lt;p&gt;— Sagasu, building DMtheFounder&lt;/p&gt;

</description>
      <category>startup</category>
      <category>webdev</category>
      <category>database</category>
      <category>serverless</category>
    </item>
  </channel>
</rss>
