Skip to content
Go back

Garrul: 53 Releases Later

By SumGuy 12 min read
Garrul: 53 Releases Later
Contents

Same disclosure as last time: I wrote Garrul, it runs the comment section on this blog, and everything below is one guy grading his own work. Take it as a status report, not a review.

Back in May, at v1.8.0, I wrote this about what was coming next: “better moderation tooling, performance work on the embed widget, and some UX polish on the admin UI, but I’m not going to publish a feature list and then be held to it in the comments.” Eighty-nine days and 53 releases later, all three shipped. I’d like to say that was the plan all along. It wasn’t. I refused to commit to a roadmap specifically so nobody could hold me to it, and then went and did the thing anyway. Fifty-three releases for one part-time side project is a strange number to sit with, and most of them were not the three things I promised. Let’s look at what actually landed, grade it honestly, and get into the parts that were never on that list at all, because that’s where the interesting work happened.

Grading My Own Homework

Moderation tooling. This is the one I’d have bet on failing, and it’s the one that got the most attention. Roles and saved replies landed almost immediately in v1.9.0, then v1.17.0 added reader reporting, per-post close and auto-close, and one-click ban-author. v1.14.0 made the queue itself usable: relative timestamps, click-to-copy comment ids, and a body column wide enough to actually read instead of squinting at wrapped text in a thin cell. The muted-words blocklist in v2.12.0 is the release I use the most: it’s editable from the admin panel and takes effect on the next comment, no redeploy, no waiting on a deploy pipeline to block a slur that just showed up in your queue. v2.14.0 let moderators write ad hoc replies from the admin panel instead of only picking from presets, which sounds small until you’re three replies deep into “no, that’s not how Docker networking works” and the canned response doesn’t fit. v2.9.0 added moderator email digests so you stop refreshing the queue out of habit, and v2.22.0 closed the loop with moderator notes and a keyboard-driven queue: j/k to move, a to approve, s for spam. Along the way, v1.16.0 turned the existing webhook adapters into something worth reading: Discord notifications became color-coded embeds with the commenter’s avatar and a direct link into the admin queue, instead of a flat text blob you had to decode. That’s a legitimate before-and-after. Grade: shipped, and shipped deeper than I planned.

Performance on the embed widget. Also shipped, in two different senses. The gzipped bundle went from 16.13 KB to 13.38 KB in v2.8.1, a 17 percent cut, mostly by minifying widget CSS at build time instead of shipping it as a commented template literal. Then v2.15.0 collapsed widget mount from up to five Worker requests down to one, via a single /api/v1/bootstrap call that answers config, session, the first page of comments, and engagement state together. On the free tier ceiling of 100,000 requests a day, cutting mount from as many as five requests to one buys up to five times the pageview headroom. And v2.2.0 stopped loading Turnstile’s iframe on every page render: it now mounts the first time a visitor focuses the composer, so a reader who never comments never pays for the anti-spam check at all. v2.20.0 kept going on the request count itself: posting a comment used to cost two Worker calls, one to submit and one to reload the thread, and now it renders your comment straight from the submit response, so it costs one. Grade: shipped, with numbers to back it up, which is more than “performance work” usually gets from me.

UX polish on the admin UI. Shipped, though “polish” underselled it. v1.12.0 was a full admin and widget redesign with built-in dark mode. v2.17.0 added per-comment permalinks and fixed a genuinely bad accessibility bug: the dark widget theme had a transparent background, so dropped onto a light host page its body text measured 1.23:1 contrast against white, nowhere near the 4.5:1 WCAG floor. It now paints its own background and measures 14.81:1. v2.18.0 closed out the remaining accessibility gaps and added a visual-regression harness so that class of bug gets caught before a release instead of after. My favorite fix in this bucket, though, is v2.5.0: reply threads had been dead-ending four levels deep, with no Reply button anywhere past that point, even though the server had always accepted replies down to depth 8 and rendered them fine. The widget was enforcing a limit nobody asked it to enforce. That’s not polish so much as noticing the software had been lying to people for months. v2.21.0 closed a smaller version of the same problem: the Edit button used to be drawn once and never re-checked, so a reader who left a comment page open past their edit window came back to a button that looked live, an editor that prefilled fine, and a silent 403 on Save. It now carries a countdown in the last hour of the window and disables itself cleanly at expiry, instead of pretending nothing changed.

The Stuff Nobody Asked For

The more interesting half is what nobody asked for, because that is where the last three months went that the May roadmap never mentioned.

Telegram operator integration (v1.18.0) turned out to be the sleeper hit. You bind a chat id, and moderation actions, comment queries, and a daily digest all arrive as Telegram messages with inline approve, spam, and ban buttons. I did not plan this. I built it because I got tired of context-switching to a browser tab to deal with one spam comment.

Localization (v2.7.0) put German, Spanish, and French into the widget, the notification emails, and the Atom feed. Worth saying plainly: these three are machine-seeded, meaning an LLM translated them and no native speaker has checked the output yet. They’re gated behind an explicit DEFAULT_LOCALE setting or a per-page data-lang attribute and are never auto-selected from a host page’s <html lang>, so nobody gets a surprise translation they didn’t ask for.

Compliance (v2.6.0, with v2.1.0 ahead of it) is the category nobody asks for until they suddenly, urgently do. Per-user data export answers an actual GDPR Article 15 request as a JSON download. Audit-log retention lets old moderation records age out on a timer instead of accumulating forever. An optional hashed-IP retention window (v2.1.0) enforces its 7-day floor inside the sweep rather than in the settings clamp. Set it to 2 and the value is accepted but nothing ever sweeps, which is deliberate: the floor refuses to silently rewrite an operator’s explicit choice. None of this existed in May. All of it exists because running a comment system that stores IP hashes and email addresses means you’re a data controller whether you planned to be one or not.

Subscriptions (v2.10.0, v2.11.0) gave readers a bell to follow a thread without commenting on it, a self-service panel to manage what they follow, and one-click unsubscribe that actually works from inside Gmail or Outlook via RFC 8058. The reaction bar also got recut: the old 👍 sat directly on top of the up-vote and meant the same thing twice, so it became 🔥 with a real label next to it.

Auth breadth (v1.13.0) added Facebook, X, and Discord sign-in next to GitHub and Google, with PKCE added to the flow because X requires it. Two footguns worth knowing if you run this: X hands back no email address, so those users can’t be auto-promoted through ADMIN_EMAILS and have to be promoted manually from the admin panel. And Instagram isn’t coming, because Meta retired the API it would have needed.

Security posture and ops rounded it out. v2.19.0 added sign-out-everywhere session revoke and an RFC 9116 security.txt endpoint for vulnerability disclosure. v2.3.0 gave the rate limiter an optional Durable Object backend, because the default Cache API version can’t do an atomic compare-and-swap and counts per-colo, so a distributed burst gets undercounted. v2.4.0 put a hard, atomic ceiling on outbound subscription-confirmation email, closing a gap where an attacker cycling email addresses could run up your mail bill and your sending domain’s spam reputation without ever tripping the per-identity limiter. And v1.21.0 fixed something that had been quietly rotting since the project started: configuration lived in six hand-maintained lists that drifted apart from each other, an example env file here, a wrangler template there, a docs table nobody updated. One registry now generates all of it, all 53 entries, with a CI check that fails the build on drift. Less visible than a feature, more load-bearing than most of them.

The 2.0.0 Elephant

I’m going to undersell v2.0.0 here on purpose, because the audit got its own post and duplicating it would waste both articles. Short version: a security audit on 2026-08-01 turned up 2 critical and 5 high findings, plus 14 medium ones, and fixing them properly meant landing eight breaking changes at once. Three of those will take a running instance down if you don’t act on them before deploying: IP_HASH_SECRET and JWT_SECRET are now enforced and every route 500s without them, IP-keyed writes now require the cf-connecting-ip header, and the iframe embed only posts to origins listed in ALLOWED_ORIGINS. If you’re upgrading an instance from before August, read that other post first. This one assumes you already have.

What I Got Wrong

A few of these are worth admitting outright instead of burying in a changelog.

v2.12.0 shipped a muted-words feature and, in the same release, an admin settings page that didn’t render and couldn’t save. A stray double quote inside an Alpine.js attribute closed it early and the browser silently ate everything after. v2.12.1 fixed it the next day. v2.7.1 exists because npm run upgrade kept replaying breaking changes people had already handled: a hop from 2.5.0 to 2.7.0 printed nine “manual steps required” blocks, and eight of them were already done. v1.22.1 exists because /admin/usage rejected perfectly valid Cloudflare API tokens for months, because Garrul only checked one of Cloudflare’s two token-verification endpoints. And the reply-depth bug from v2.5.0 means the widget was quietly wrong about how deep a thread could go while the server had been right the entire time. I only found it because someone hit it.

The one I’m most embarrassed by predates this whole run: v1.19.0 fixed editing a comment opening to a blank box instead of your original text. You’d click Edit, stare at an empty field, and either retype from memory or give up. That’s the kind of bug you only find by using your own software as a reader, not as its author, and I hadn’t been doing enough of that.

And the cadence itself deserves an honest word. Fifty-three releases in eighty-nine days isn’t a brag, it’s a confession: five of them shipped inside 24 hours on 2026-08-03 (v2.0.1 through v2.1.0), which is what “moving fast” looks like from the inside, and it looks less like momentum and more like patching yesterday’s patch. Some of that pace is a one-person project with no release-approval bottleneck. Some of it is that a few of those releases shouldn’t have needed to exist.

If you’re running Garrul and none of this rang a bell, that’s the goal: read the security audit post before your next upgrade, run npm run upgrade to see your own diff, and file the next bug instead of quietly patching around it. Fifty-four is coming either way.

Common Questions

Can I upgrade Garrul from 1.x straight to 2.22.0?

Yes, in one hop. All migrations from v1.9.0 onward are forward-only and idempotent, so npm run migrate applies whatever you’re missing in order regardless of your starting version. Run npm run upgrade first: it lists every breaking change and new required secret between your installed version and the target before you deploy.

Does Garrul run anywhere other than Cloudflare Workers?

No. Garrul is Workers-native by design, built around Workers, D1, KV, and Turnstile specifically, with no other backend targeted or tested. It has no supported deployment path to another host, and porting it elsewhere means rewriting the storage and edge-caching layers, not changing a config flag.

How big is the Garrul widget now?

20.26 KB gzipped as of v2.21.0, against a CI-enforced 30 KB ceiling. It dropped to 13.38 KB after the v2.8.1 minification fix, then grew back as features like i18n, subscriptions, and accessibility fixes landed. Every pull request reports its gzip delta against the base branch before merge.

Can Garrul import comments from Disqus?

Yes, since v1.9.0. The importer enforces a byte-accurate 50 MB upload cap and runs from an admin route or the CLI. Note that the CLI path was a silent no-op through v1.22.x due to a wrangler response-parsing bug fixed in v2.0.0: if a past CLI import looked like it did nothing, it did nothing, and needs a re-run.

Which languages does the Garrul widget support?

English by default, plus German, Spanish, and French since v2.7.0. Those three are machine-seeded translations that no native speaker has reviewed yet, reachable only through an explicit DEFAULT_LOCALE setting or a per-page data-lang attribute, never auto-selected from a host page’s language tag.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Home Assistant Energy Dashboard with Shelly

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts