<?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: aiomniu</title>
    <description>The latest articles on DEV Community by aiomniu (@aiomniu).</description>
    <link>https://dev.to/aiomniu</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%2F4025039%2Fb33a4e0f-2375-4363-8282-4cdfed63d3bb.jpg</url>
      <title>DEV Community: aiomniu</title>
      <link>https://dev.to/aiomniu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aiomniu"/>
    <language>en</language>
    <item>
      <title>Building a Freelance Platform on Cloudflare Workers: The Good, The Bad, and The Pyodide</title>
      <dc:creator>aiomniu</dc:creator>
      <pubDate>Sat, 11 Jul 2026 10:55:01 +0000</pubDate>
      <link>https://dev.to/aiomniu/building-a-freelance-platform-on-cloudflare-workers-the-good-the-bad-and-the-pyodide-nm3</link>
      <guid>https://dev.to/aiomniu/building-a-freelance-platform-on-cloudflare-workers-the-good-the-bad-and-the-pyodide-nm3</guid>
      <description>&lt;h1&gt;
  
  
  Building a Freelance Platform on Cloudflare Workers: The Good, The Bad, and The Pyodide
&lt;/h1&gt;

&lt;p&gt;Six months ago I sat down to build &lt;a href="https://aiomniu.top" rel="noopener noreferrer"&gt;aiomniu&lt;/a&gt; — a freelance marketplace for AI and tech projects. The constraints were simple: zero budget, fast load times, and a backend I could write in Python.&lt;/p&gt;

&lt;p&gt;The stack I ended up with: &lt;strong&gt;Cloudflare Workers + Pyodide + FastAPI&lt;/strong&gt; on the backend, &lt;strong&gt;Next.js 15 + OpenNext&lt;/strong&gt; on the frontend, and &lt;strong&gt;Cloudflare D1&lt;/strong&gt; for the database.&lt;/p&gt;

&lt;p&gt;This is what I learned — the parts that worked, the parts that broke, and the parts I'm still not sure were worth it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Cloudflare Workers?
&lt;/h2&gt;

&lt;p&gt;I did the obvious comparison:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Cost for MVP&lt;/th&gt;
&lt;th&gt;Cold Start&lt;/th&gt;
&lt;th&gt;Python Support&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vercel&lt;/td&gt;
&lt;td&gt;Free (but DB adds up)&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Serverless functions only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Lambda&lt;/td&gt;
&lt;td&gt;Cheap but complex&lt;/td&gt;
&lt;td&gt;200ms-1s&lt;/td&gt;
&lt;td&gt;Good (via Layers)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Railway&lt;/td&gt;
&lt;td&gt;$5/mo&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Workers&lt;/td&gt;
&lt;td&gt;Free tier is generous&lt;/td&gt;
&lt;td&gt;Sub-ms&lt;/td&gt;
&lt;td&gt;Pyodide (Python→Wasm)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For me it came down to two things: &lt;strong&gt;free tier&lt;/strong&gt; and &lt;strong&gt;edge distribution&lt;/strong&gt;. Cloudflare Workers gives you 100k requests/day at no cost, and your code runs in 330+ locations. Hard to beat for a bootstrap budget.&lt;/p&gt;

&lt;p&gt;The catch is that Python on Workers runs through &lt;strong&gt;Pyodide&lt;/strong&gt; — CPython compiled to WebAssembly. It's not serverless Python in the traditional sense. It's Python that has been through a compiler, stuffed into a Wasm binary, and told to behave. Most of the time it does. Sometimes it doesn't.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Good Parts
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Deploying is Surprisingly Simple
&lt;/h3&gt;

&lt;p&gt;Once you have the pipeline, deployment is one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uvx &lt;span class="nt"&gt;--from&lt;/span&gt; workers-py pywrangler deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads your &lt;code&gt;pyproject.toml&lt;/code&gt;, bundles FastAPI and all its dependencies, and pushes it to the edge. No Docker. No Lambda Layers. No VPC configs. Coming from AWS, this felt like cheating.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. D1 is Just SQLite — and That's Great
&lt;/h3&gt;

&lt;p&gt;D1 is Cloudflare's serverless SQLite. It supports proper SQL, joins, indexes, transactions. For an MVP with ~60 tables, it's been rock solid.&lt;/p&gt;

&lt;p&gt;The best part is local development. I keep a local &lt;code&gt;aiomniu.db&lt;/code&gt; file and switch connection logic based on the environment:&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="c1"&gt;# db.py
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_get_local_conn&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aiomniu.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;row_factory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Row&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;

&lt;span class="c1"&gt;# On Workers, same SQL through D1
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;query_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DB&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;params&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;_clean_nulls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same SQL. Same schema. Just different connection code. This saved me countless hours during development.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Next.js + OpenNext Just Works
&lt;/h3&gt;

&lt;p&gt;The frontend is a separate Worker built with &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt;. It converts the Next.js build output into a Workers-compatible bundle. SSG pages pre-render at build time, ISR pages revalidate hourly.&lt;/p&gt;

&lt;p&gt;The SEO setup is clean — each page uses &lt;code&gt;generateMetadata()&lt;/code&gt; for dynamic meta tags and JSON-LD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&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;generateMetadata&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Metadata&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&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;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Hire &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SKILLS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; Developers | aiomniu`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Find pre-vetted &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SKILLS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; developers for your project.`&lt;/span&gt;&lt;span class="p"&gt;,&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;h3&gt;
  
  
  4. Programmatic SEO Actually Works
&lt;/h3&gt;

&lt;p&gt;With zero domain authority, I couldn't compete head-to-head. So I built &lt;strong&gt;alternatives pages&lt;/strong&gt; — targeting people searching for competitors:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;What It Targets&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/alternatives/upwork&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"Upwork alternatives"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/alternatives/fiverr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"Fiverr alternatives"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/hire/react-developer/in/sydney&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"Hire React developer Sydney"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each page is data-driven. Add one line to a config array, and a new SEO page appears with its own meta tags, content, and JSON-LD:&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;SKILL_CITY_COMBOS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;react-developer&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="s"&gt;shanghai&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;python-developer&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="s"&gt;beijing&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;react-developer&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="s"&gt;sydney&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="c1"&gt;# 250+ more combos
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generates 300+ SEO pages from about 50 lines of configuration. Google has indexed most of them. The long-tail strategy is working — we get impressions for queries we'd never rank for on the homepage.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Not-So-Good Parts
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Pyodide is NOT Python 3.12 on Your Laptop
&lt;/h3&gt;

&lt;p&gt;This is the biggest trap. It looks like Python, but:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No thread pool.&lt;/strong&gt; At all. Every route handler &lt;strong&gt;must&lt;/strong&gt; be &lt;code&gt;async def&lt;/code&gt;. Synchronous routes crash with &lt;code&gt;RuntimeError: can't start new thread&lt;/code&gt; — because there's literally no thread to start.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Dynamic imports don't work.&lt;/strong&gt; &lt;code&gt;__import__()&lt;/code&gt; and &lt;code&gt;importlib.import_module()&lt;/code&gt; fail silently. All imports must be static &lt;code&gt;from X import Y&lt;/code&gt; at the module top level.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The ASGI bridge was broken for months.&lt;/strong&gt; The &lt;code&gt;asgi&lt;/code&gt; import in &lt;code&gt;workers-py&lt;/code&gt; had a version check bug that rejected valid versions. It was fixed in workers-py 1.11.0, but I spent a week debugging before figuring that out.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;JsProxy is everywhere.&lt;/strong&gt; Every JavaScript value arrives as a &lt;code&gt;JsProxy&lt;/code&gt; object. You need explicit conversion functions:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_to_py&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pyodide.ffi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;to_js&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;hasattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;to_py&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_py&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ImportError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;  &lt;span class="c1"&gt;# local dev — no Pyodide
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_null&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;D1 NULL comes through as empty dict {}. Convert back to None.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;val&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ended up writing &lt;strong&gt;five&lt;/strong&gt; utility functions just to handle Pyodide type conversions: &lt;code&gt;_to_py()&lt;/code&gt;, &lt;code&gt;_deep_to_py()&lt;/code&gt;, &lt;code&gt;_null()&lt;/code&gt;, &lt;code&gt;_clean_nulls()&lt;/code&gt;, and &lt;code&gt;_convert_bind_params()&lt;/code&gt;. Each one addresses a different flavor of the same problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The 138KB Upload Wall
&lt;/h3&gt;

&lt;p&gt;Pyodide's multipart form parsing dies at roughly 138KB. Not gracefully — it just hangs or crashes.&lt;/p&gt;

&lt;p&gt;I had to add explicit file size checks before any upload:&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upload_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...):&lt;/span&gt;
    &lt;span class="n"&gt;content_length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&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-length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&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;content_length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;138&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JSONResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&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="s"&gt;File too large&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;413&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This forced me to use &lt;strong&gt;Cloudflare R2&lt;/strong&gt; for file storage — uploads go directly via presigned URLs instead of through the Python Worker. It's the right architecture for production, but discovering it through a silent crash was frustrating.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The Worker Bundle Size Limit
&lt;/h3&gt;

&lt;p&gt;The free plan limits Worker code to &lt;strong&gt;10MB gzipped&lt;/strong&gt;. My FastAPI + Pydantic + dependencies bundle is ~2.15MB, so I had room. But it's a hard ceiling — every new dependency needs a &lt;code&gt;pip install&lt;/code&gt; size check.&lt;/p&gt;




&lt;h2&gt;
  
  
  The One Bug That Cost Me Days
&lt;/h2&gt;

&lt;p&gt;D1 returns SQL &lt;code&gt;NULL&lt;/code&gt; values as &lt;strong&gt;empty JsProxy objects&lt;/strong&gt;, which Pyodide's &lt;code&gt;.to_py()&lt;/code&gt; converts to empty Python dicts &lt;code&gt;{}&lt;/code&gt;. Not &lt;code&gt;None&lt;/code&gt;. An empty dict.&lt;/p&gt;

&lt;p&gt;So a query like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;bio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;avatar&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;bio&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt; returns &lt;code&gt;{"bio": {}, "avatar": {}}&lt;/code&gt; instead of &lt;code&gt;{"bio": None, "avatar": None}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I discovered this when I saw "{}" rendered on a user profile page. Not a great look.&lt;/p&gt;

&lt;p&gt;The fix runs on every single query result:&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;def&lt;/span&gt; &lt;span class="nf"&gt;_clean_nulls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;_null&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&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;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. But it's the kind of thing you only discover by accidentally shipping it to production.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Skip Pyodide for New Projects
&lt;/h3&gt;

&lt;p&gt;If I were starting today, I'd use &lt;strong&gt;Workers with JavaScript/TypeScript&lt;/strong&gt; for the API layer, or &lt;strong&gt;Hono&lt;/strong&gt; (the framework built for Workers). Pyodide's quirks add too much overhead. The "write Python, deploy to edge" promise is real, but the edge is jagged.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Go Next.js from Day One
&lt;/h3&gt;

&lt;p&gt;The site originally had a Vue 3 frontend. When I migrated to Next.js 15, everything got faster — SSG eliminated initial load time, SEO metadata became cleaner, and the developer experience was noticeably better.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Connect GSC Earlier
&lt;/h3&gt;

&lt;p&gt;I didn't set up Google Search Console until after the first 100 pages were indexed. I have no idea what the early crawl behavior was. Connect it on day one — it's free and gives you crawl stats, index coverage, and search query data.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;After 3 months of zero-budget operation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~300 SEO pages indexed&lt;/strong&gt; (mostly long-tail skill/city combos)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sub-100ms median response time&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~$0 hosting cost&lt;/strong&gt; (still on the free tier)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The site is live at &lt;a href="https://aiomniu.top" rel="noopener noreferrer"&gt;aiomniu.top&lt;/a&gt; if you want to see it in action.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/workers/languages/python/" rel="noopener noreferrer"&gt;Cloudflare Workers Python docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pyodide.org/" rel="noopener noreferrer"&gt;Pyodide project&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://opennext.js.org/cloudflare" rel="noopener noreferrer"&gt;OpenNext for Cloudflare&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/d1/" rel="noopener noreferrer"&gt;D1 database docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Built something on Workers + Pyodide? What broke for you? I'd honestly love to hear — misery loves company.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;— aiomniu-pilot&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>python</category>
      <category>nextjs</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
