A Reaction Bar That Remembers You Without Knowing You
At the bottom of every post now there is a little row of emojis. You tap π₯, the number ticks up, and if you close the tab and come back tomorrow your π₯ is still lit. It is the smallest possible interaction on the whole site, and it turned out to be the one I thought hardest about.
The reason is that the moment I let anyone react without an account, I inherited a problem I did not have before. A number anyone can click is a number anyone can lie to. There is no login to dedupe on, no session that means anything, nothing to stop a bored person with a loop from turning 12 into 12,000. And yet I wanted the number to feel honest, to remember what each person picked, and to never once ask them to sign in.
So the question this whole piece answers is a narrow one: how do you count clicks from strangers, remember what each stranger chose, and still believe the total, without ever knowing who any of them are?
Three wishes that fight each other#
Lay the wishes next to each other and you can feel them pulling in different directions.
Anonymous fights honest: with no account there is no natural identity to count one-per-person against. Anonymous fights durable too: to show you the emojis you already picked, I have to recognise you when you come back, and recognising you is a kind of identity. And the obvious fixes make it worse. One reaction per IP is both too strict (a whole office shares one address) and too loose (a phone changes address on the train). A cookie is forgotten the moment someone clears it. A login solves everything and violates the first wish outright.
The way out is to stop looking for one perfect identity and use two disposable ones, each doing a smaller job.
Two identities, not one#
Give each reader two cheap, throwaway handles. A visitor id, a random UUID the browser generates and keeps, answers exactly one question: which of these reactions are mine? A salted hash of the IP answers a different one: is this the same network hammering the button? Neither is a login. Both are disposable. Together they are enough to make the count honest.
The whole mechanism lives in two database indexes.
Every insert is ON CONFLICT DO NOTHING, so both guards are enforced by Postgres itself, not by application code that could race. The first index is about you: it means your reaction is a fact, not a counter you keep nudging, and it is why tapping π₯ twice leaves one row and why your π₯ is still there next week. The second index is about everyone else: even if a script clears all its state and mints a fresh visitor id on every request, the IP hash it cannot easily change collides on the same (slug, emoji, ip_hash) and the insert quietly does nothing. I salt and hash the IP so a raw address is never stored, and I read Cloudflare's cf-connecting-ip first because it is stable across a visitor's requests.
That is the entire trust model: two indexes, one about you and one about your network, and a database that refuses to double-count.
Where the id lives, so it survives you clearing it#
The visitor id only works if it sticks around, and browsers are hostile to anything that sticks around. Clear your cookies and a cookie-only id is gone. So I do not trust any single store. The id is written to three at once, and each read heals the others.
It is redundancy for a thing that does not matter much, which is the point: none of it is precious, so I can afford to be sloppy and generous. If all three are wiped you simply become a new visitor, and the server falls back to identifying you by IP hash alone, so the count stays roughly honest even for someone who scrubs everything between clicks. Here are the four handles I could have reached for, and why I landed on this one.
No single row of that table is enough. The system is really the top three stacked: the visitor id for memory, the IP hash for spam, and graceful degradation to IP-only when someone has cleared themselves out of existence.
The write is a toggle, never a counter#
Identity is only half of trust. The other half is making sure that, even correctly identified, you cannot do much damage. So the client never sends a number. It sends intentions: for each emoji, on or off, and nothing else.
The consequences of "toggle, not counter" are the quiet safety of the whole thing. There is no request shape that adds five, so nobody can add five. An off deletes exactly one row, yours, and only when it exists, so nobody can drive a count below the reactions they personally made. Each emoji is normalized to Unicode NFC and checked with Intl.Segmenter to be exactly one grapheme of real emoji code points, so the field can never smuggle in arbitrary text: no "π₯DROP TABLE", no essays, just one π₯. The API surface is deliberately too small to abuse.
Batching the clicks#
There is a performance reason for the toggle batching too, not just a safety one. Reactions are clicky. People tap π₯ then β€οΈ then change their mind, three or four times in a second or two. Firing a request per tap is wasteful and, worse, it races: responses arrive out of order and the number flickers.
So the UI is optimistic and the network is patient. A tap flips the number instantly with React's useOptimistic, before anything hits the server, so the bar always feels immediate. Meanwhile the real toggles collect in a Map where the last action for each emoji wins, and only 800 ms after your last tap does a single batch go out. If it fails on a flaky connection it retries once rather than silently dropping your reaction.
The subtle case is the one where you react and immediately close the tab. A normal request would be cancelled as the page tears down, and your reaction would evaporate. So the last flush goes out with keepalive on the pagehide event, which lets the POST outlive the page, and unlike navigator.sendBeacon it can still carry the visitor-id header that tells the server the reaction is yours.
One write, every open tab#
A reaction you cannot see other people make is a lonelier thing than it needs to be. So counts are live. When a write changes the numbers, the API publishes them to a Redis pub/sub channel, and every open Server-Sent Events stream for that post pushes the fresh counts down to its browser.
Redis is doing the fan-out on purpose. The API runs as more than one process, and a reaction can land on any of them, so an in-memory event bus would only reach the readers who happen to share a process with the writer. Publishing to Redis means every process, and therefore every reader, hears every write. On the receiving side a reader sees the count roll up and a small pulse ring bloom on the exact emoji someone else just tapped, which is a surprisingly warm little signal for something so cheap to send.
The same stream carries more than counts. It also carries settings, so when I block a post or curate its emoji set from the admin panel, the change is pushed live: the bar can hide itself the instant a post is closed to reactions, for everyone currently reading it, with no refresh. It is the same SSE plumbing I lean on elsewhere on this backend, pointed at a smaller job.
An honest number#
One rule I held to is that the number is never dressed up. It is exactly the count of rows in the table, with no phantom baseline added to make a quiet post look busier than it is. Your own reactions sort to the front, then the rest by popularity, so the bar reads like a little summary of where you and everyone else landed.
That honesty cost me one genuinely annoying bug. The pills reorder as counts change (your picks jump to the front), and reordering the DOM nodes made the rolling-number animation detach and drift out of its badge every time a pill moved. The fix was to keep a stable DOM order and express rank purely with CSS order, so a reposition is instant and the digits stay put, then isolate the NumberFlow animation so it moves with its badge instead of relative to a layout that is shifting underneath it. It is a tiny detail, invisible when right and deeply broken-feeling when wrong, which is most of frontend.
Where it breaks#
None of this survives a determined adversary, and it is worth being plain about that.
The IP hash is a blunt instrument in both directions. Behind CGNAT or a corporate NAT, many real people share one address, so the anti-spam guard will stop the second genuine person on that network from adding the same emoji. One office, one π₯. That is a deliberate trade: I would rather undercount a shared network than let a single machine inflate a number, but it is an undercount, and on a popular post from a big company it is a real one.
And the whole thing is spam resistance, not ballot integrity. The visitor id is client-generated and unauthenticated, so someone with a script and a pool of IP addresses can still pad a count. I am comfortable with that because the stakes are a blog's emoji tally. If the number ever meant something, a vote, a giveaway, a leaderboard, none of this would be enough, and I would have to reach for exactly the account-based identity the whole design exists to avoid.
The live layer has an honest cost too. Every reader on a post holds an open SSE connection for as long as the tab is on that post, which is fine at a blog's traffic but is a real resource, not a free one. At a different scale you would coalesce or shed those connections, or move to periodic polling for idle readers. It buys warmth, and warmth is not free.
What it actually takes to trust a click#
I came to this expecting the honest-anonymous-counter problem to need something heavy: a fingerprint, a proof-of-work, an account after all. It needed none of those. It needed two disposable identities each doing one small job, a write shaped so it can only move your own single row, and the discipline to show the real number instead of a flattering one.
The through-line, the same one I keep finding, is that trust without accounts is mostly about shrinking the blast radius. Make the worst thing any stranger can do be exactly the one honest thing you invited them to do: tap an emoji, once. Everything else, the self-healing id, the debounce, the keepalive, the Redis fan-out, is comfort built on top of that single idea. The button is small. What makes it trustworthy is that I made it impossible for it to be anything bigger. β€οΈ
References
- PostgreSQL: INSERT ... ON CONFLICT DO NOTHING
- PostgreSQL: unique indexes
- Redis: Pub/Sub
- MDN: Using server-sent events
- MDN: EventSource
- React: useOptimistic
- MDN: Request.keepalive (a POST that outlives the page)
- MDN: the pagehide event
- MDN: String.prototype.normalize (Unicode NFC)
- MDN: Intl.Segmenter (grapheme counting)
- Unicode UTS #51: Unicode Emoji
- MDN: IndexedDB API
- Cloudflare: HTTP request headers (cf-connecting-ip)
- Hono: the web framework this API runs on
- Drizzle ORM
- frimousse: the headless emoji picker
- NumberFlow: the animated number component
- Motion: the animation library
Related articles
A follow-up to the Vasarely piece. How I generate deterministic avatars from a username, first as ordered-dithered identicons, then as halftone dot fields where the dot size does the drawing.
A design decision, not a demo. Why I encoded a whole VPS as one Go CLI, pfrctl, instead of renting a PaaS or carrying a heavy self-hosted panel, and where that trade-off pays off.
A defender's-eye tour of how modern anti-bot systems actually work in 2026, from passive TLS and HTTP fingerprints to an obfuscated client that probes your browser, server-side ML scoring, and tokens that expire in seconds. Plus an honest split of what a small team can build and what it has to rent.