<?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: Dmytro Oriekhov</title>
    <description>The latest articles on DEV Community by Dmytro Oriekhov (@dmytrooriekhov).</description>
    <link>https://dev.to/dmytrooriekhov</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3956227%2F4ec3f8be-ba9d-43ce-bc9f-5b8a6b924f9c.png</url>
      <title>DEV Community: Dmytro Oriekhov</title>
      <link>https://dev.to/dmytrooriekhov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dmytrooriekhov"/>
    <language>en</language>
    <item>
      <title>I migrated my Python backend to Cloudflare Workers in 4 hours and got 12x speedup</title>
      <dc:creator>Dmytro Oriekhov</dc:creator>
      <pubDate>Fri, 29 May 2026 12:51:22 +0000</pubDate>
      <link>https://dev.to/dmytrooriekhov/i-migrated-my-python-backend-to-cloudflare-workers-in-4-hours-and-got-12x-speedup-3fc2</link>
      <guid>https://dev.to/dmytrooriekhov/i-migrated-my-python-backend-to-cloudflare-workers-in-4-hours-and-got-12x-speedup-3fc2</guid>
      <description>&lt;p&gt;Two days ago I launched &lt;a href="https://jobpilot-ai.pages.dev" rel="noopener noreferrer"&gt;JobPilot AI&lt;/a&gt; on Product Hunt — a privacy-first job-search PWA. The frontend runs 100% in the browser, the backend is a small Python service that aggregates 11 job boards.&lt;/p&gt;

&lt;p&gt;After the launch I watched the first real users hit "Live search" and wait. And wait. Then 30 seconds later the results came back.&lt;/p&gt;

&lt;p&gt;Render's free tier sleeps after 15 minutes of inactivity. The first request after sleep takes 30-60 seconds for the container to spin up. For a product whose entire value prop is "fast and private", that's a fatal first impression.&lt;/p&gt;

&lt;p&gt;So I decided to migrate to Cloudflare Workers. This is the story of that 4-hour port.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers (after the migration)
&lt;/h2&gt;

&lt;p&gt;Same query, same data sources, fresh test:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Render (free, cold)&lt;/th&gt;
&lt;th&gt;CF Worker&lt;/th&gt;
&lt;th&gt;Speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/search&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30.9 sec&lt;/td&gt;
&lt;td&gt;2.6 sec&lt;/td&gt;
&lt;td&gt;12x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/parse-url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;22.8 sec&lt;/td&gt;
&lt;td&gt;1.4 sec&lt;/td&gt;
&lt;td&gt;16x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the bonus:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cold start: gone entirely. Workers boot in ~10ms.&lt;/li&gt;
&lt;li&gt;Errors after 24 hours of production traffic: 0&lt;/li&gt;
&lt;li&gt;Cost: $0. Free tier gives 100k requests/day, I'm at ~50/day.&lt;/li&gt;
&lt;li&gt;Global: 320 edge locations vs Render's 1 US region.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What had to change
&lt;/h2&gt;

&lt;p&gt;The Python backend was 670 lines covering 11 job sources, SSRF protection, rate limiting, caching, and HTML parsing. Here's how each piece translated.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ThreadPoolExecutor&lt;/code&gt; → &lt;code&gt;Promise.all&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Workers JS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="nf"&gt;fetchRemotive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;fetchArbeitnow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;fetchHimalayas&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="c1"&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;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One-liner. &lt;code&gt;fetch()&lt;/code&gt; doesn't block the event loop, so 12 parallel HTTP calls cost almost the same as one.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;urllib.request&lt;/code&gt; → &lt;code&gt;fetch&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Python's &lt;code&gt;urllib&lt;/code&gt; makes you handle gzip manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2_000_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Encoding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gzip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decompress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Workers &lt;code&gt;fetch&lt;/code&gt; handles compression for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&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;text&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// already decompressed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  HTMLParser → regex
&lt;/h3&gt;

&lt;p&gt;Most pages needed only a handful of fields: &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;, JSON-LD &lt;code&gt;hiringOrganization&lt;/code&gt;. For that, a regex is fine and 10x simpler to read than a stateful parser. Workers do ship an &lt;code&gt;HTMLRewriter&lt;/code&gt; API for streaming HTML, but the page is bounded at 80KB anyway.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&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;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&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="sr"&gt;/&amp;lt;title&lt;/span&gt;&lt;span class="se"&gt;\b[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;([\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;title&amp;gt;/i&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&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;ogTitle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pickAttr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;meta&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;property&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;og:title&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  In-memory cache → Cloudflare Cache API
&lt;/h3&gt;

&lt;p&gt;Python kept results in a &lt;code&gt;dict&lt;/code&gt; with a &lt;code&gt;threading.Lock&lt;/code&gt;. That dies on every cold start. Workers have a built-in edge cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cacheGet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://cache.local/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&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;req&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cacheSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://cache.local/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;max-age=300&lt;/span&gt;&lt;span class="dl"&gt;"&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&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 synthetic URL is just a stable key. &lt;code&gt;ctx.waitUntil&lt;/code&gt; lets the cache write happen after the response is already on its way to the user.&lt;/p&gt;

&lt;h3&gt;
  
  
  SSRF protection: simpler in Workers
&lt;/h3&gt;

&lt;p&gt;In Python I had a manual SSRF guard checking DNS-resolved IPs against private ranges. In Workers, this is largely unnecessary: &lt;strong&gt;the runtime cannot reach private IP space at all&lt;/strong&gt;. I still validate scheme and hostname to reject &lt;code&gt;file://&lt;/code&gt;, &lt;code&gt;localhost&lt;/code&gt;, &lt;code&gt;.internal&lt;/code&gt; TLDs, but the heavy lifting is done for me by the platform.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isSafeUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;urlStr&lt;/span&gt;&lt;span class="p"&gt;)&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;urlStr&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;urlStr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;urlStr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&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;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;localhost&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.internal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rate limiting: dropped (for now)
&lt;/h3&gt;

&lt;p&gt;Python had a per-IP sliding window with a &lt;code&gt;threading.Lock&lt;/code&gt;. In Workers, in-process state is per-isolate and unreliable. The proper move is Durable Objects, but those aren't free anymore. For an indie product at 50 req/day, Cloudflare's built-in DDoS protection is enough — I'll add real rate limiting when traffic grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deploy story
&lt;/h2&gt;

&lt;p&gt;I did NOT cut over straight to the Worker. Instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Built the Worker in parallel under a new URL (&lt;code&gt;jobpilot-api.dima-orehov-id.workers.dev&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Added a feature flag in the frontend: &lt;code&gt;localStorage.api_backend = "worker"&lt;/code&gt; routes to the new backend; absence routes to Render.&lt;/li&gt;
&lt;li&gt;A/B tested with myself for an hour — verified that all 10 sources return the same shape of data.&lt;/li&gt;
&lt;li&gt;Flipped the default in code: the frontend now defaults to Worker, with a &lt;code&gt;localStorage.api_backend = "render"&lt;/code&gt; escape hatch.&lt;/li&gt;
&lt;li&gt;Kept the Render service alive for one week as a hot fallback.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the Worker had blown up at step 4, the rollback was a one-line code change plus a redeploy — total recovery time under 10 minutes. Render staying up meant the escape hatch actually worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open source the migration diff.&lt;/strong&gt; I kept the repo private during launch. In hindsight, an open &lt;code&gt;worker.js&lt;/code&gt; would have been a stronger artifact for this very article.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with Cyrillic queries earlier.&lt;/strong&gt; I auto-translate Cyrillic search terms to English using MyMemory's free API; that path wasn't smoke-tested until later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop dead sources first.&lt;/strong&gt; Three job boards (Findwork 401, No Fluff Jobs 403, EuroJobs 404) were broken before the migration; I ported them anyway, then ripped them out and replaced with Working Nomads + RemoteOK API + Jobicy v2 JSON. Cleaner result.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cost comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Render free&lt;/th&gt;
&lt;th&gt;CF Workers free&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Daily request budget&lt;/td&gt;
&lt;td&gt;"until it sleeps"&lt;/td&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold start&lt;/td&gt;
&lt;td&gt;30-60 sec&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Global edge&lt;/td&gt;
&lt;td&gt;1 region&lt;/td&gt;
&lt;td&gt;320 locations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In-built KV/Cache&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost above free&lt;/td&gt;
&lt;td&gt;$7/mo&lt;/td&gt;
&lt;td&gt;$5/mo (10M req)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For low-traffic indie products, Workers free tier is borderline absurd. 100k requests/day is enough for 10k daily users at 10 req/user. By the time you outgrow it, you've already won.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Live demo: &lt;strong&gt;&lt;a href="https://jobpilot-ai.pages.dev" rel="noopener noreferrer"&gt;jobpilot-ai.pages.dev&lt;/a&gt;&lt;/strong&gt; — click "Live search", open DevTools → Network, watch the request hit &lt;code&gt;*.workers.dev&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Happy to answer questions about the port, the privacy model, or running production on free tiers.&lt;/p&gt;

</description>
      <category>cloudflarechallenge</category>
      <category>javascript</category>
      <category>python</category>
      <category>performance</category>
    </item>
    <item>
      <title>Local-first job search aggregator - 400KB of vanilla JS, no framework</title>
      <dc:creator>Dmytro Oriekhov</dc:creator>
      <pubDate>Thu, 28 May 2026 09:27:36 +0000</pubDate>
      <link>https://dev.to/dmytrooriekhov/local-first-job-search-aggregator-400kb-of-vanilla-js-no-framework-40lk</link>
      <guid>https://dev.to/dmytrooriekhov/local-first-job-search-aggregator-400kb-of-vanilla-js-no-framework-40lk</guid>
      <description>&lt;p&gt;I just launched &lt;strong&gt;JobPilot AI&lt;/strong&gt; on Product Hunt - a job search tool that runs entirely in the browser. No signup, no cloud, no tracking. Your CV never leaves your device.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;Every "free" job board I tried was quietly selling my data to recruiters I never asked for. Got tired of it. So I made one that doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Vanilla JS, no framework, no build step&lt;/li&gt;
&lt;li&gt;~400KB total payload&lt;/li&gt;
&lt;li&gt;All user data lives in &lt;code&gt;localStorage&lt;/code&gt; - profile, CV text, saved jobs, settings&lt;/li&gt;
&lt;li&gt;Resume parsing in-browser: PDF.js for PDFs, Mammoth.js for DOCX&lt;/li&gt;
&lt;li&gt;Match scoring: local TF-IDF-style algorithm + skill keyword overlap. No LLM API call.&lt;/li&gt;
&lt;li&gt;Reply text: template-based with style variants, also local&lt;/li&gt;
&lt;li&gt;PWA with a service worker, installable, works offline after first load&lt;/li&gt;
&lt;li&gt;5 languages (EN/RU/UK/DE/PL), no cookies&lt;/li&gt;
&lt;li&gt;Hosted on Cloudflare Pages (free), Python backend on Render free tier&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Total monthly cost: $0&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Live search
&lt;/h2&gt;

&lt;p&gt;Aggregates 12+ public job board APIs in one call:&lt;br&gt;
Remotive, Himalayas, Arbeitnow, Jobicy, Work.ua, Djinni, Freelancer, RemoteOK, We Work Remotely, No Fluff Jobs.&lt;/p&gt;

&lt;p&gt;Only keyword + location go out. No PII, no resume content transmitted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What surprised me building it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Public job board APIs aren't standardized at all. Each one has a different shape, different rate limits, different "remote" flag conventions.&lt;/li&gt;
&lt;li&gt;Relevance filtering had to be client-side - backends return loosely-matched jobs, so I do a second pass against the user's resume locally.&lt;/li&gt;
&lt;li&gt;Cookieless analytics (Cloudflare Web Analytics) is genuinely a thing now. Privacy-first doesn't have to mean "no data ever".&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stuff I'm thinking about
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;The local match scoring vs cloud LLM trade-off - speed and privacy on one side, smarter ranking on the other. For now I'm sticking local.&lt;/li&gt;
&lt;li&gt;Whether "no signup" is too radical for the average user (most expect cloud sync).&lt;/li&gt;
&lt;li&gt;How to eventually reconcile privacy-first with "but I want recruiters to find me".&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Live: &lt;a href="https://jobpilot-ai.pages.dev/" rel="noopener noreferrer"&gt;https://jobpilot-ai.pages.dev/&lt;/a&gt;&lt;br&gt;
Product Hunt: &lt;a href="https://www.producthunt.com/products/jobpilot-ai?launch=jobpilot-ai" rel="noopener noreferrer"&gt;https://www.producthunt.com/products/jobpilot-ai?launch=jobpilot-ai&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Honest feedback welcome - especially "would I actually use this?" answers and tech critique.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>showdev</category>
      <category>buildinpublic</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
