{"slug": "building-a-freelance-platform-on-cloudflare-workers-the-good-the-bad-and-the", "title": "Building a Freelance Platform on Cloudflare Workers: The Good, The Bad, and The Pyodide", "summary": "A developer built aiomniu, a freelance marketplace for AI and tech projects, using Cloudflare Workers with Pyodide for Python backend support. The stack includes FastAPI, Next.js 15, and Cloudflare D1 database, leveraging edge distribution and a generous free tier. The developer highlights challenges with Pyodide's limitations, such as the lack of thread pools and the requirement for async route handlers.", "body_md": "Six months ago I sat down to build [aiomniu](https://aiomniu.top) — 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.\n\nThe stack I ended up with: **Cloudflare Workers + Pyodide + FastAPI** on the backend, **Next.js 15 + OpenNext** on the frontend, and **Cloudflare D1** for the database.\n\nThis is what I learned — the parts that worked, the parts that broke, and the parts I'm still not sure were worth it.\n\nI did the obvious comparison:\n\n| Platform | Cost for MVP | Cold Start | Python Support |\n|---|---|---|---|\n| Vercel | Free (but DB adds up) | Fast | Serverless functions only |\n| AWS Lambda | Cheap but complex | 200ms-1s | Good (via Layers) |\n| Railway | $5/mo | Fast | Native |\n| Cloudflare Workers | Free tier is generous | Sub-ms | Pyodide (Python→Wasm) |\n\nFor me it came down to two things: **free tier** and **edge distribution**. Cloudflare Workers gives you 100k requests/day at no cost, and your code runs in 330+ locations. Hard to beat for a bootstrap budget.\n\nThe catch is that Python on Workers runs through **Pyodide** — 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.\n\nOnce you have the pipeline, deployment is one command:\n\n```\nuvx --from workers-py pywrangler deploy\n```\n\nIt reads your `pyproject.toml`\n\n, 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.\n\nD1 is Cloudflare's serverless SQLite. It supports proper SQL, joins, indexes, transactions. For an MVP with ~60 tables, it's been rock solid.\n\nThe best part is local development. I keep a local `aiomniu.db`\n\nfile and switch connection logic based on the environment:\n\n``` python\n# db.py\ndef _get_local_conn():\n    conn = sqlite3.connect(\"aiomniu.db\")\n    conn.row_factory = sqlite3.Row\n    return conn\n\n# On Workers, same SQL through D1\nasync def query_all(env, sql, *params):\n    db = env.DB\n    result = await db.prepare(sql).bind(*params).all()\n    return [_clean_nulls(dict(r)) for r in result[\"results\"]]\n```\n\nSame SQL. Same schema. Just different connection code. This saved me countless hours during development.\n\nThe frontend is a separate Worker built with `@opennextjs/cloudflare`\n\n. It converts the Next.js build output into a Workers-compatible bundle. SSG pages pre-render at build time, ISR pages revalidate hourly.\n\nThe SEO setup is clean — each page uses `generateMetadata()`\n\nfor dynamic meta tags and JSON-LD:\n\n```\nexport async function generateMetadata({ params }): Promise<Metadata> {\n  const { slug } = await params;\n  return {\n    title: `Hire ${SKILLS[slug].name} Developers | aiomniu`,\n    description: `Find pre-vetted ${SKILLS[slug].name} developers for your project.`,\n  };\n}\n```\n\nWith zero domain authority, I couldn't compete head-to-head. So I built **alternatives pages** — targeting people searching for competitors:\n\n| Page | What It Targets |\n|---|---|\n`/alternatives/upwork` |\n\"Upwork alternatives\" |\n`/alternatives/fiverr` |\n\"Fiverr alternatives\" |\n`/hire/react-developer/in/sydney` |\n\"Hire React developer Sydney\" |\n\nEach 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:\n\n```\nSKILL_CITY_COMBOS = [\n    ('react-developer', 'shanghai'),\n    ('python-developer', 'beijing'),\n    ('react-developer', 'sydney'),\n    # 250+ more combos\n]\n```\n\nThis 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.\n\nThis is the biggest trap. It looks like Python, but:\n\n**No thread pool.** At all. Every route handler **must** be `async def`\n\n. Synchronous routes crash with `RuntimeError: can't start new thread`\n\n— because there's literally no thread to start.\n\n**Dynamic imports don't work.** `__import__()`\n\nand `importlib.import_module()`\n\nfail silently. All imports must be static `from X import Y`\n\nat the module top level.\n\n**The ASGI bridge was broken for months.** The `asgi`\n\nimport in `workers-py`\n\nhad 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.\n\n**JsProxy is everywhere.** Every JavaScript value arrives as a `JsProxy`\n\nobject. You need explicit conversion functions:\n\n``` python\ndef _to_py(val):\n    try:\n        from pyodide.ffi import to_js as _\n        if hasattr(val, 'to_py'):\n            val = val.to_py()\n    except ImportError:\n        pass  # local dev — no Pyodide\n    return val\n\ndef _null(val):\n    \"\"\"D1 NULL comes through as empty dict {}. Convert back to None.\"\"\"\n    if isinstance(val, dict) and len(val) == 0:\n        return None\n    return val\n```\n\nI ended up writing **five** utility functions just to handle Pyodide type conversions: `_to_py()`\n\n, `_deep_to_py()`\n\n, `_null()`\n\n, `_clean_nulls()`\n\n, and `_convert_bind_params()`\n\n. Each one addresses a different flavor of the same problem.\n\nPyodide's multipart form parsing dies at roughly 138KB. Not gracefully — it just hangs or crashes.\n\nI had to add explicit file size checks before any upload:\n\n``` python\nasync def upload_file(request: Request, ...):\n    content_length = int(request.headers.get(\"content-length\", 0))\n    if content_length > 138 * 1024:\n        return JSONResponse({\"error\": \"File too large\"}, status_code=413)\n```\n\nThis forced me to use **Cloudflare R2** 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.\n\nThe free plan limits Worker code to **10MB gzipped**. My FastAPI + Pydantic + dependencies bundle is ~2.15MB, so I had room. But it's a hard ceiling — every new dependency needs a `pip install`\n\nsize check.\n\nD1 returns SQL `NULL`\n\nvalues as **empty JsProxy objects**, which Pyodide's `.to_py()`\n\nconverts to empty Python dicts `{}`\n\n. Not `None`\n\n. An empty dict.\n\nSo a query like:\n\n```\nSELECT bio, avatar FROM users WHERE id = ?\n```\n\nWhere `bio`\n\nis `NULL`\n\nreturns `{\"bio\": {}, \"avatar\": {}}`\n\ninstead of `{\"bio\": None, \"avatar\": None}`\n\n.\n\nI discovered this when I saw \"{}\" rendered on a user profile page. Not a great look.\n\nThe fix runs on every single query result:\n\n``` php\ndef _clean_nulls(d: dict) -> dict:\n    if not isinstance(d, dict):\n        return d\n    return {k: _null(v) for k, v in d.items()}\n```\n\nIt works. But it's the kind of thing you only discover by accidentally shipping it to production.\n\nIf I were starting today, I'd use **Workers with JavaScript/TypeScript** for the API layer, or **Hono** (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.\n\nThe 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.\n\nI 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.\n\nAfter 3 months of zero-budget operation:\n\nThe site is live at [aiomniu.top](https://aiomniu.top) if you want to see it in action.\n\n*Built something on Workers + Pyodide? What broke for you? I'd honestly love to hear — misery loves company.*\n\n— aiomniu-pilot", "url": "https://wpnews.pro/news/building-a-freelance-platform-on-cloudflare-workers-the-good-the-bad-and-the", "canonical_source": "https://dev.to/aiomniu/building-a-freelance-platform-on-cloudflare-workers-the-good-the-bad-and-the-pyodide-nm3", "published_at": "2026-07-11 10:55:01+00:00", "updated_at": "2026-07-11 11:14:47.032206+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "ai-infrastructure"], "entities": ["Cloudflare Workers", "Pyodide", "FastAPI", "Next.js", "Cloudflare D1", "aiomniu", "OpenNext"], "alternates": {"html": "https://wpnews.pro/news/building-a-freelance-platform-on-cloudflare-workers-the-good-the-bad-and-the", "markdown": "https://wpnews.pro/news/building-a-freelance-platform-on-cloudflare-workers-the-good-the-bad-and-the.md", "text": "https://wpnews.pro/news/building-a-freelance-platform-on-cloudflare-workers-the-good-the-bad-and-the.txt", "jsonld": "https://wpnews.pro/news/building-a-freelance-platform-on-cloudflare-workers-the-good-the-bad-and-the.jsonld"}}