I sat down to build RSVPs for three grapplers. I did not sit down to write a QR encoder.
Five weeks later there is a 600-line QR encoder inlined into worker.js. There is a PNG generator written in 80 lines of stdlib Python because I needed a 512x512 favicon and refused to install a library. There is a hand-rolled markdown parser, because /articles had to render markdown and I could not bring myself to type npm install marked. There is a multipart form parser, also by hand, because Cloudflare Workers' formData() is broken for binary uploads and the fix is to read the raw ArrayBuffer and walk the bytes yourself. None of this was on the plan. The plan was three grapplers and an RSVP form.
The plan got away from me. It got away from me earlier too.
What did not get away — what I held onto, week over week, commit over commit — is the property that package.json does not exist in this repo. There is no node_modules. There is no package-lock.json. There is no npm install. When you clone this codebase, you do not download four hundred packages from a registry that neither you nor I have ever audited. You download one file from git.
This article is about why. It is also about what that doesn't fix.
The threat model
This is not a hypothetical. The last few years of supply-chain incidents have been a steady, named, dated drumbeat:
event-stream(2018). A maintainer handed off ownership to a stranger. The stranger addedflatmap-streamas a sub-dependency.flatmap-streamwas a wallet-stealing backdoor targeting a single downstream package.event-streamhad two million weekly downloads at the time of the handoff.ua-parser-js(2021). Maintainer's npm account was compromised. Three malicious versions shipped with a cryptominer and a password stealer. The package had ~8 million weekly downloads. Blast radius: anyone who rannpm installduring the seven-hour window before the maintainer noticed and ripped the versions down.node-ipc(2022). The maintainer themselves wrote the malware. A logic bomb in a popular logging library overwrote files on disk for users whose IP geo-located to Russia or Belarus. The protest was the maintainer's; the code ran on every machine that pulled the package, regardless of whether you'd signed up for political activism with your build pipeline.polyfill.io(2024). A widely-used JavaScript CDN got sold. The new owners served malware to roughly 100,000 sites that were embedding the polyfill via a<script src>. Not technically an npm incident — exact same threat model.xz-utils(2024). Not JavaScript. Not npm. A multi-year social-engineering campaign against a single maintainer of a Linux compression library. Came within weeks of a backdoor landing insshdon every major distro. The detection was an accident — a Microsoft engineer chasing a 500-millisecond regression in his Postgres benchmark.
The throughline is not "npm is bad." The throughline is transitive trust does not scale. When a project depends on fifty packages, and those packages depend on twenty more, you are not trusting fifty maintainers. You are trusting thousands. Each one is a single bad day, a single compromised laptop, a single sold GitHub handle away from running code inside your build. The math is unkind and getting worse.
What "no dependencies" actually looks like
The instinct, when you hear a project lives without npm, is to imagine Spartan minimalism — the developer sits cross-legged at a stone keyboard, refusing the modern conveniences. In practice it's not that. It's a series of small no's, where each no makes the codebase slightly bigger and the trust surface slightly smaller. This part of the platform spiralled exactly the way the rest of it spiralled: one decision at a time, none of them planned.
Receipts:
- Authentication. Email-only OTP, magic-link tokens, HMAC-SHA256 cookie signing, all written by hand. No
next-auth, nopassport, no Auth0 SDK. The whole flow is about 400 lines. - The QR encoder for the apex
/joinflow. Nayuki's MIT-licensed QR generator, about 600 lines, inlined directly intoworker.js. I read it, the function names got prefixed withqrso they wouldn't collide with anything in the rest of the file, and it shipped. Noqrcode.jspackage, no transitive dependency tree. - The favicon. A 32x32 ICO and a 512x512 PNG, both generated by a Python script that builds the byte stream by hand with
structandzlib. Stdlib only. Eighty lines. Output gets base64-encoded into aconstinworker.jsand served from there. - Markdown for
/articles. A handwritten parser that supports exactly what the articles need: headings, paragraphs, bold, italic, inline code, links, blockquotes, lists, horizontal rules, fenced code blocks. No tables. No images. No nested lists. About 120 lines. Nomarked, noremark, no MDX. I will extend it the day an article actually needs a table. - Multipart parsing for avatar uploads.
request.formData()corrupts binary data inside Cloudflare Workers. The workaround is to read the rawArrayBuffer, walk the boundary delimiters, slice out the file body manually. Nomulter, nobusboy. About sixty lines. - The deployment pipeline. A Python script,
deploy.py, that POSTsworker.jsto the Cloudflare REST API with amultipart/form-databody. No Wrangler. No npm. No Node. Ifpython3runs on your machine, you can deploy this thing. - Geocoding. Direct HTTP fetch against Nominatim with a proper
User-Agentheader. Nonode-geocoder. No SDK. Twenty lines. - Sending email. Direct HTTP fetch against Resend's REST API. No Resend SDK. The SDK would be one more package to trust, for an HTTP call that's already fifteen lines of
fetch.
None of these are virtuoso engineering. They are small pieces of unexciting code. The cumulative effect is that the entire dependency graph of this platform fits in your head.
The same posture extends to the Python side. The ops toolchain — deploy.py (POSTs worker.js to Cloudflare's REST API), setup.py (one-time KV namespace creation), backup.py (snapshots KV + R2 to a tar.gz), restore.py (the inverse, gated by a "type YES RESTORE to proceed" prompt), migrate_training_to_year.py (a one-shot data migration), generate_favicon.py (the 80-line PNG generator from earlier) — all of it runs in a venv whose pip freeze returns nothing.
Every import in every script comes from the Python standard library: argparse, json, urllib.request, tomllib, subprocess, pathlib, tarfile, hashlib, struct, zlib. The favicon generator is struct plus zlib. The Cloudflare "API client" is urllib.request with a Bearer header on the requests. The config parser is tomllib, which has shipped in Python since 3.11. There is no requests. There is no python-dotenv. There is no cloudflare SDK, no boto3, no pydantic. There is no requirements.txt. There is one external subprocess call, to git rev-parse --short HEAD for cache-busting the worker, with a hardcoded argv list and a timestamp fallback if git isn't on the box.
The argument was never about JavaScript specifically. It was about transitive trust. The same threat model applies in any language with a package manager, and the same answer — "use the standard library; write the rest yourself" — applies in any language with a usable standard library.
What we give up
Honest accounting:
- Type-checking. There is no TypeScript build step. The codebase is vanilla JS with JSDoc-style comments where it matters. A few classes of bug that TypeScript would catch at compile time, I catch at runtime — usually in staging, occasionally in prod.
- A rich text editor.
/articlesis markdown, edited as plain text in a<textarea>in the admin panel. There is no WYSIWYG. There is no toolbar. If I want bold text I type**and I move on. - Charts. The platform admin dashboard renders bar charts in pure CSS with
<div>columns and percentage heights. They look fine. They are not Recharts. Anyone who needs interactive zoomable time-series visualizations is on the wrong platform. - Hot module reload. Every code change is a full deploy. Deploys take about eight seconds.
- A frontend framework. No React, no Vue, no Svelte. The page re-renders on every navigation. Forms POST and redirect. This is, in 2026, considered "old-fashioned." It is also, not coincidentally, considered "fast."
What I do not give up by skipping npm: a frontend that works, a deployment that is reliable, and a codebase I can read end-to-end in an afternoon.
The dependencies we do have
I want to be precise here, because "zero dependencies" as a marketing claim is almost always a lie and I am not going to start.
The platform has four runtime dependencies:
- Cloudflare. Workers, KV, R2. The whole stack runs on a single vendor. If Cloudflare has a bad day, the platform has a bad day. The mitigation is: Cloudflare runs more nines than I do, and a single vendor with a contractual SLA is a fundamentally different threat model from a thousand npm maintainers with no contract at all.
- Resend. Transactional email — OTPs, invite links, messaging notifications. A single SaaS, called over HTTPS. One vendor, one API key, narrow blast radius.
- Nominatim. OpenStreetMap's geocoding service, used to convert city names to lat/lng for the throwdown board's radius search. Best-effort, wrapped in try/catch, never blocks a save. The platform degrades gracefully if it goes away.
- Google Fonts. Noto Serif and Inter. Served via the standard Google Fonts CDN with the appropriate CSP entries. Could be self-hosted; isn't, because the CDN is faster than I am.
Four named relationships, four contracts I can read. That is qualitatively different from "we import 47 packages which transitively import 800 more, none of whose maintainers I can name." The dependency tree fits on a napkin. When something changes upstream, I know where to look.
This is the actual delta. Not "zero" versus "some." Few-and-named versus many-and-anonymous.
What this doesn't fix
Now the part I owe you.
Removing npm from the equation closes one class of bug: the class where a maintainer I have never met ships code that runs in my build pipeline tomorrow morning. It does not close the class where I myself ship code that has a bug in it.
There are 26,695 lines of JavaScript in worker.js. They were written by one person over thirty-seven days, with a pair-programmer (Claude Code) that is very good at making things compile and only adequate at noticing security implications I haven't already flagged. The first ten thousand lines were written before I'd implemented addSecurityHeaders. The CSP nonce migration happened in week four. The training-key consolidation that cuts heatmap reads by 100× shipped the day before this article was drafted. The codebase has the shape of an artefact that was sprinted into existence and is still being hardened around the edges.
A non-exhaustive list of things I think are reasonably solid:
- The CSRF posture (cookies scoped to host,
SameSite=Lax, every POST behindrequireMember). - The XSS posture (one
escHtmlfor all rendered user content, onesafeJsonfor everything injected into<script>blocks, CSP nonces blocking anything inline by default,/csp-reportcapturing violations to the platform activity log). - The auth flow (HMAC-signed cookies, server-side revocation via
revokedAt, OTP rate-limited per email and per hashed IP, 5-attempt cap, 15-minute TTL). - The cross-tenant posture (every KV key prefixed by
groupId, every read throughgetData/setData,groupIdalways derived from theHostheader and never trusted from input).
A non-exhaustive list of things I do not yet know are bugs:
- Whatever I have not thought to test.
- Whatever combination of three handlers, in an unusual sequence, races against itself in a way I have not yet seen in the dev console.
- Whatever vulnerability hides in Resend, Nominatim, the Google Fonts CDN, or Cloudflare itself. I trust them more than I'd trust 800 npm maintainers. I do not trust them infinitely.
- Whatever class of bug I do not yet have the vocabulary to name.
This is the honest part. The platform might have a critical vulnerability you haven't found. I might have a critical vulnerability I haven't found. The right person to find one of these is not me, alone in my apartment, but a stranger on the internet who happens to look. If that stranger is you: there is a .well-known/security.txt on every domain. The contact is info@grapplers.club. Responsible disclosure gets a fast reply and a fix; the alternative does not.
The argument for "zero npm" is not that the platform is therefore secure. The argument is that one large category of risk has been deliberately removed, so the remaining risk fits in a smaller box that one person can reason about. That box is still not empty. It is just small enough to inspect.
Where this lands
The reason this post exists is that the previous one ended on "I should probably have stopped at the WhatsApp group" — which is true, and which is also incomplete. The thing that also happened, in those five weeks, is that I made a series of small architectural decisions whose only justification at the time was "yeah, but no" — yeah I could pull in this package, but no, I think I'd rather not — and the accumulated weight of those small no's is what makes the codebase auditable today.
I did not plan to write a QR encoder. I did not plan to hand-roll a multipart parser. I did not plan, especially, to inline an MIT-licensed PNG generator that I will probably never need to modify again. None of those choices were strategic when I made them. Each one was a hesitation. Each hesitation, in retrospect, was a small loan I declined to take out against future me's threat surface.
Three grapplers. One worker.js file. Zero npm packages. Some unknown number of bugs.
Still better than the WhatsApp group.