Same disclosure as last time: I wrote Garrul, it runs the comment section on this blog, and what follows is one guy reporting on his own work. Treat it as a status update, not a review.
The short version: I shipped 24 releases since the last status post, v2.23.0 through v2.40.0, and the biggest ones made Garrul cheaper to run. Fewer Worker requests per pageview, fewer D1 rows read, fewer KV writes, and a dashboard that finally tells you where the usage goes. There was no roadmap this round, so this is a themed recap instead of a scorecard. Cost comes first because that’s the part a self-hoster can act on today.
Cheaper by Counting Things
If you run a comment system on Cloudflare’s developer platform, you pay for three things: Worker requests, D1 rows read and written, and KV operations. The biggest releases in this stretch tried to spend less of one of them.
v2.27.0 went after the mount. The config response now carries form_token_enabled, and the widget skips the form-token request when it’s false. A default install (no SPAM_FORM_TS_SECRET, or SPAM_HONEYPOT_MIN_MS=0) mounts in one Worker request. The same release changed how reactions and votes load: batched IN lists for the comments on the returned page only, instead of the whole post. Engagement rows for comments nobody asked to see stop being read.
v2.38.0 moved a file off the Worker entirely. /embed.js can now be served from Workers Static Assets through an [assets] block. A static asset request doesn’t run your Worker code, so it stops counting against the Worker request quota. That is why the change is a config edit instead of a code change. The same release stopped minting the form-timing token at widget mount. The token now comes on first composer focus, so a reader who only reads never pays for it. Settings moved from a KV cache to a 60 s per-isolate memo, reply notifications fan out in one query, and thread paging seeks on new indexes (migration 0030). The trade: settings edits can take up to 60 s to reach every isolate. I’ll take that for the KV reads it removes.
v2.39.0 was the big one, with five changes:
/embed/?slug=<slug>is now a static iframe shell served by Workers Static Assets, so an iframe pageview no longer runs the Worker for the shell./embed/<slug>is unchanged.- Page vote and reaction totals are stored on write. Nothing counts them per pageview anymore.
- Sessions are read once per request and refreshed at half-life.
/feed/:slugand/c/:idare edge-cached for 5 minutes. - Anonymous comment posts no longer write KV, because the Workers AI verdict cache is gone.
- Siteverify, Akismet and Workers AI calls now time out at 5 s, 3 s and 5 s.
The last item looks like reliability work, but a slow third party that hangs a Worker invocation is a reliability problem too, and your 2 AM self does not enjoy debugging a comment form that spins forever because Akismet had a bad night.
Why rows read is the number that matters
D1 bills rows read, not queries. The v2.26.1 security review (below) showed why: an unindexed lookup reads the whole table, and a loop of random tokens against it becomes a bill. A query that scans 10,000 rows costs 10,000 reads even though you only sent one request.
So v2.39.0 made /admin/usage count D1 rows read and written, not queries. It also added a Workers AI neurons panel. Every anti-spam control on /admin/settings now states its per-comment cost next to the toggle. If you’re deciding whether to turn on Akismet and the AI check together, the page tells you what each one adds per comment. One caveat: dashboard figures can trail a moderation action by up to a minute, though the moderation queue itself stays live.
The config lines
Two edits in your own wrangler.toml cover most of this. npm run upgrade does not sync wrangler.toml keys, so you add them by hand:
# name, main, routes, workers_dev and the rest of the top-level keys stay up hereminify = true
# [assets] goes at the END of the file[assets]directory = "./public"The ordering gotcha is real. In TOML, a key written below a table header belongs to that table. Put minify = true under [assets] and the key belongs to the [assets] table, not to the Worker config. Keep [assets] below the top-level keys (name, main, routes, workers_dev) and you’re fine. Add it before you run npm run upgrade so the upgrade’s deploy picks it up. If you add it later, run npm run deploy. Skip it entirely and nothing breaks. You just keep paying Worker requests for a JavaScript file.
v2.40.0 added minify = true to wrangler.example.toml, which makes the upload about a third smaller with no behavior change. If you serve from a custom domain, also set workers_dev = false (and preview_urls = false) so the workers.dev hostname isn’t public. That one is hygiene, not savings, but it’s the same file and you’re already in there.
One more constraint on the static shell: its CSP comes from the top-level ALLOWED_ORIGINS in wrangler.toml at build time. Change the list and you must redeploy. With ENV = "dev", [env.*] tables, or no ALLOWED_ORIGINS, the Worker serves that URL itself.
Moderation and Spam
The cost work paid for itself, but a comment system still has to survive actual commenters.
v2.30.0 brought pinned comments (one approved top-level comment per post, with a Pinned badge), an opt-in staff badge for moderator replies, and six new reaction kinds you can choose and order per surface. It also added a site-wide export at /admin/operator, or GET /admin/api/export?format=json|csv. An export too big for D1’s per-request query budget answers 413 export_too_large, and the fallback is a command you run yourself:
npm run db:exportv2.30.1 fixed a hole in the first-comment hold. SPAM_FIRST_COMMENT_MODERATE held only a new author’s very first comment, so a second comment posted while the first sat in the queue went live. It now holds an author’s comments until you approve one of them. A comment you marked spam or deleted no longer counts as history either.
v2.31.0 added reserved names (a list under Admin, Settings, Moderation) and gave each IP-and-name pair its own anonymous identity. Two people behind one IP posting under different names no longer share an author. Bans stay IP-wide and are now checked before Turnstile.
v2.37.0 fixed a thread-structure problem. Marking a comment spam, holding it, or deleting it used to hide its approved replies too. Now the parent renders as an anonymous “[removed by a moderator]” placeholder, and the replies survive. When the whole thread is garbage, the Spam thread action marks a comment and every reply under it spam in one atomic step, up to 100 comments. Each changed row gets a thread.spam audit entry with prev_status, so you can undo it.
v2.38.0 changed the AI check. Workers AI spam checks now use Cloudflare’s Clef-flash decision model and score each comment. The cut-off is SPAM_AI_THRESHOLD, from 1 to 100 with a default of 70, and it’s also editable on /admin/settings. If you want to know how that class of model behaves, I wrote up a measured comparison separately. Here I only report what Garrul now calls.
Locales and Importers
This section is short because most of it is a list.
v2.28.0 added six locales: Italian, Japanese, Norwegian Bokmål, Dutch, Polish and Portuguese. Polish and Norwegian Bokmål were checked by native speakers, and they’re the only non-English locales that auto-select from your host page’s <html lang>. The other four are machine-seeded and opt-in through data-lang. A contributor added Traditional Chinese (zh-Hant) in v2.29.0, also machine-seeded and opt-in. v2.36.0 added Arabic, the first right-to-left locale. The widget sets its direction from the locale. Select it with data-lang="ar". An Arabic <html lang> alone still gives you English until a native speaker reviews the translation.
On the import side, v2.23.0 added dumpers for isso and Cusdis. Neither ships an export command, so a small Node script reads the source’s own database and writes a JSON dump the importer takes:
npm run dump-issonpm run import-isso
npm run dump-cusdisnpm run import-cusdisThe isso dumper reads comments.db with Node’s built-in SQLite, so there are no new dependencies. Both imports are idempotent: re-running the same dump inserts zero rows. v2.36.1 later fixed a stranded-page problem for threads whose links the read API would reject, giving them a stable synthetic slug. Earlier imports may still have stranded pages, and the repo’s AGENTS-OPERATE.md has the SQL to find and move them.
Setup, Upgrades, and the Security Review
Setup. v2.32.0 made npm run setup hostname-first. It asks for the hostname, picks your Cloudflare account once, reuses database and KV ids that belong to that account, and needs far fewer prompts. It also checks ALLOWED_ORIGINS, PUBLIC_BASE_URL, OAUTH_CALLBACK_BASE and ADMIN_EMAILS as you type, and exits nonzero when a required check fails. npm run owner-link prints a single-use admin sign-in link.
Upgrades. Three releases made npm run upgrade refuse to guess:
- v2.24.0 added an auth preflight. If wrangler isn’t signed in, the run stops and prints wrangler’s own error. Before, a failed secret list or migration query produced an “all migrations pending, all secrets missing” plan against a healthy deployment.
- v2.24.1 lets it send a
GITHUB_TOKENorGH_TOKENfor release lookups, and rate-limit 403s now name their cause. - v2.28.2 fixed
npm run migratetreating unreadable migration history as “nothing applied” and replaying every file. It now aborts before touching anything.
Then v2.35.0 made the upgrade print the notes for every release since your installed version. The newest three show in full and older ones show as one title line each, capped at 100 lines. It costs one extra GitHub API request:
GH_TOKEN=$(gh auth token) npm run upgradeThe security review. An external review of 2.26.0 turned up three cases where an existing mitigation was real but did not reach the case found. The fixes in v2.26.1:
subscriptions.tokenhad no index, so lookups scanned the table on two endpoints with no session. Migration 0024 adds the index, and the lookup rejects anything that isn’t 64 lowercase hex before it reaches D1.- The OAuth binding cookie is
__Host-prefixed in production. An unprefixed name is writable by any host under your eTLD+1, so a sibling subdomain could plant its own state and cookie pair. posts.published_at, which anchorsAUTO_CLOSE_DAYS, is now written only by the request that creates the post row, so a later comment can’t stamp an old date and close a thread for good.
Earlier, v2.25.0 shipped a hardening pass of its own: the markdown renderer moved to v3 (inline text escaped unconditionally, tag allowlist fails closed), commenter-supplied post URLs are followed only on an ALLOWED_ORIGINS origin, and the emailed subscription confirm link now needs a button press so mail-scanner prefetch can’t confirm it. The original audit has the background on how I approach this.
What Broke or Needed a Manual Step
You should know where the sharp edges are before you upgrade.
-
v2.39.0 needs a post-deploy SQL step. Run
npm run upgrade(migrations 0031 and 0032), then run this once:Terminal window npx wrangler d1 execute DB --remote --file src/db/post-deploy/0032_page_engagement_totals.sqlIt recounts vote and reaction totals that the old Worker changed between migrate and deploy. It’s idempotent, and upgrades from this release on run it automatically.
-
v2.40.0 keys don’t sync.
npm run upgradedoes not touchwrangler.toml. Theminify = trueline and theworkers_dev = falseadvice are yours to add. -
v2.26.1 causes a one-time login blip. I renamed the OAuth binding cookie with no legacy-name fallback on purpose. A sign-in started in the 600 seconds before your deploy fails once with
invalid state. Retrying works. -
v2.24.0 is breaking. The
likereaction alias, kept since the v2.10.0 rename tofire, is gone.POST kind: "like"on either reaction route answers400 invalid_kind. The widget has sentfiresince v2.10.0, so only hand-written clients and very old cached bundles are affected.npm run upgradeflags it as a breaking change. -
v2.25.0 requires a rerender. After deploying, run
npm run rerender -- --remote(or use Admin, Operator, Rerender), because storedbody_htmlmust be regenerated for renderer v3.
If something here bites you anyway, file the issue. I’d rather hear about it than have you patch around it quietly.
So, Cheaper
Twenty-four releases, and the pattern in the biggest ones is plain. A default install mounts in one Worker request. The script that loads the widget can sit on Static Assets. The iframe shell does too. Vote totals are stored, not recounted. The dashboard counts the rows you actually pay for. None of that is a headline feature, and that’s how I like it. A comment system you can forget about because it does less work per pageview is the kind worth self-hosting. If you want the origin story, why I built Garrul covers it. The next 24 will probably be more of the same: fewer things Garrul does per pageview.
Common Questions
How do I upgrade Garrul across several versions?
Run npm run upgrade. Since v2.35.0, Garrul prints the release notes for every version between your installed one and the target. The newest three show in full and older ones show as title lines. Check for manual steps first, such as the v2.39.0 post-deploy SQL command and the v2.25.0 rerender.
How do I move comments from isso to Garrul?
Run npm run dump-isso to read your comments.db into a JSON dump, then npm run import-isso or upload it on the admin operator page. Garrul added isso support in v2.23.0. The import is idempotent, so re-running the same dump inserts zero rows. Cusdis works the same way with its own dump and import commands.
Does Garrul support right-to-left languages?
Yes, Arabic (ar) is supported since v2.36.0, and Garrul sets the widget’s direction from the locale. Select it with data-lang="ar". An Arabic <html lang> alone still selects English, because the machine-seeded Arabic translation waits for a native speaker to review it.
How do I serve Garrul’s embed script from Workers Static Assets?
Add an [assets] block with directory = "./public" at the end of your wrangler.toml, below the top-level keys. Garrul v2.38.0 introduced this, and npm run upgrade prints the block. Run npm run deploy if you add it after upgrading. Without the block, Garrul still works and the Worker serves /embed.js.
Where do I see Garrul’s D1 usage?
Garrul shows D1 usage at /admin/usage. Since v2.39.0, the page counts rows read and rows written instead of queries, because D1 bills rows read. It also adds a Workers AI neurons panel, and each anti-spam control on /admin/settings states its per-comment cost.