CASE STUDY 02 · FULL-STACK · EVENT SOURCING
A full event-sourced backend in ten days
A per-section applause API for the Software Mansion engineering guide — backend, a remark AST plugin, and a sendBeacon delivery path — designed, built, and shipped end-to-end in ten days.
- 10 days
- zero to production, end-to-end
- ~60
- applause buttons auto-injected by a remark plugin
- −45%
- Docker image size, after optimization
The problem
I was tasked with building a per-section applause feature for the Software Mansion engineering guide — think Medium’s clap button, but self-hosted, per heading, and analytics-first.
The requirements were clear: self-hosted (no third party for first-party content), simple enough to not drag in DevOps, easy to query for analytics, and abuse-resistant. Traffic was about 1,800 events a month — nothing that needed to scale aggressively. The part I didn’t expect was that a browser API I’d never used in production would turn out to be the most interesting decision in the whole thing.
The decision
I started with too many ideas — SQL, Mongo, Redis, a ready-made applause-button service — and no obvious winner. So before any code, I did the thing that actually saves time later: I mapped the options against the real constraints and wrote down why each one lost.
The most valuable output isn’t always the code, but rather a map of solutions and their trade-offs.
The ready-made service lost on self-hosting. A mutable counter (UPDATE count = count + 1) lost the moment I thought about abuse: once you’ve collapsed every clap into a single number, you can’t un-count a spammer without a migration. An append-only event log kept every option open. And for delivery, the obvious choice — debounce a request while the user clicks — lost to something I’ll get to below.
What I built
Contract first
Before writing any code, I defined the API contract:
GET /claps?slug={slug} → { count, max_claps, user_claps }
POST /claps?slug={slug} body: { claps: number }
max_claps lives on the server, not the client — one source of truth, no drift between the button and the rule it’s enforcing. With the contract pinned down, the frontend and backend could be built against the same spec instead of against each other.
Append-only event log
Every POST is an INSERT. No UPDATE, no upsert. The total is always SUM(claps) — computed, never stored — and IP addresses are hashed for PII compliance. The payoff is operational: if abuse shows up, I can delete events from one IP and timeframe without a schema migration, and the count simply recomputes. The “expensive” choice up front buys cheap options later.
CREATE INDEX idx_clap_events_slug ON clap_events(slug, claps);
CREATE INDEX idx_clap_events_slug_ip ON clap_events(slug, ip_hash, claps);
sendBeacon — one request per session, offline-first
The obvious approach was debouncing. I went the other way:
// text/plain avoids a CORS preflight, and the request still completes
// even if the browser is tearing the tab down mid-unload.
const body = new Blob([JSON.stringify({ claps: local.claps })], {
type: "text/plain",
});
navigator.sendBeacon(clapsUrl, body);
One request per session, fired on the way out, surviving a tab close. The trade-off is real — it’s invisible in the DevTools Network tab and harder to debug — but for a documentation analytics use case, durability beats observability. A small local.claps − local.sentClaps counter, flushed on both pagehide and visibilitychange, keeps mobile from double-sending.
A remark plugin: 60 buttons without 60 lines
I counted 60 buttons across 17 articles, each needing a slug I’d have to get right by hand — and one typo would silently break the analytics for that section. So the buttons aren’t authored; they’re generated. A remark plugin walks the Markdown AST, derives a stable slug from each heading, and injects the applause component automatically.
const text = toString(node);
const sectionSlug = slugger.slug(text);
const slug = `${articleSlug}/${sectionSlug}`;
One honest moment
The CORS preflight killed the beacon. When I sent application/json via sendBeacon, the browser fired an OPTIONS preflight first — and a preflight isn’t keep-alive, so the browser cancelled it as the tab closed. GET worked; POST showed zero. The fix was obvious in hindsight: text/plain doesn’t trigger a preflight. Switching the content type and handling it server-side took ten minutes. Working out why took a lot longer — and that’s the catch with sendBeacon: the thing you can’t see in the Network tab is the thing that’s failing.
The proof
Designed, built, and shipped end-to-end in ten days: the backend, the event-sourced data model, the remark plugin, and the client. Trimming the Docker image cut its size by 45%, which is the kind of detail nobody asks for and everybody benefits from at deploy time.
The full write-up — including the trade-off table I cut from here — is on the Software Mansion blog.
How it went
-
Thin brief, hard deadline
A per-section applause feature — self-hosted, analytics-first — and a browser API I'd never shipped to production.
-
Map the trade-offs first
Append-only event log over a mutable counter; sendBeacon over debounce. The map mattered more than the code.
-
Contract, event store, client
Defined the API contract, built the event-sourced backend, and a remark plugin that auto-injects ~60 buttons.
-
CORS killed the beacon
application/json triggered a preflight the browser cancelled on unload. text/plain fixed it — in ten minutes, after a long hunt.
-
Live in ten days
End-to-end, zero to production, plus a public write-up on the Software Mansion blog.
Working on something similar?
Book a 30-min call →