A Reaction Bar That Remembers You Without Knowing You

β€’By Pablo Fernandezβ€’12 min read

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.

logged out, no account, a reader sees this: ( + Add Reaction ) πŸ₯° 8 πŸ’» 3 ❀️ 5 πŸ”₯ 12and three quiet wishes live underneath it: anonymous no login, anyone can click honest one person, one reaction, real numbers durable your picks survive closing the tab
What a logged-out reader sees, and the three properties underneath it that do not naturally coexist.

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.

one reaction is a single row: ( slug , emoji , visitor_id , ip_hash )guarded by two UNIQUE indexes, each insert doing nothing on conflict: UNIQUE ( slug , emoji , visitor_id ) your click is idempotent: tap it twice, still one row, and it survives a browser close, so your own picks never vanish on you UNIQUE ( slug , emoji , ip_hash ) one network cannot inflate a count, even after wiping every local store and generating a brand-new visitor_id
One reaction is a single row, guarded by two unique indexes. The visitor id makes your own click idempotent; the IP hash keeps one network from inflating a count.

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.

the visitor_id is written to all three at once: cookie ┐ localStorageβ”œβ”€β”€β–Ά on load, whichever copy is still valid wins, IndexedDB β”˜ and the winner is written back to the othersclear your cookies, and localStorage or IndexedDB heals it.an IP alone would be worse: a phone hops networks, an office shares one.
The visitor id is written to a cookie, localStorage, and IndexedDB together. On load the surviving copy wins and is written back to the rest, so clearing any one store does not lose it.

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.

ApproachSurvives cookie clearSurvives network changeSpoofableNeeds loginGood for
IP address onlyyesnowith a proxynocrude anti-spam, nothing personal
Cookie onlynoyestriviallynoremembering picks, until it is cleared
Visitor id in three storesyes, heals from the othersyesyes, but disposablenoknowing which reactions are yours
A real accountyesyesnoyeswhen the count must be trustworthy

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.

you tap fast, a few times in a couple of seconds: πŸ”₯ on Β· ❀️ on Β· πŸ”₯ off Β· πŸ˜‚ onthe client does not send four requests. it records intents in aMap, last action per emoji wins, and waits for you to stop: last tap ─────▢ 800 ms of quiet ─────▢ one POSTthe batch is a set of toggles, never a delta: server: normalize each to Unicode NFC, must be ONE emoji "on" inserts your row (idempotent) "off" deletes only your own row, only if present so: the worst you can do to the public number is move the single reaction you made yourself
Rapid taps are collapsed into one debounced batch of on/off intents. The server validates each is a single emoji and only ever touches the caller's own row.

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.

one reader clicks, and every other open tab feels it:β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” publish counts β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”β”‚ POST /:slug β”‚ ──────────────────▢ β”‚ Redis pub/sub β”‚β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ every open SSE stream is subscribed β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β–Ό β–Ό β–Ό tab A tab B tab C πŸ”₯ 11 β†’ 12 πŸ”₯ 11 β†’ 12 πŸ”₯ 11 β†’ 12 a pulse ring a pulse ring a pulse ring
A write publishes to a Redis channel; every open SSE stream for that post is subscribed and streams the new counts to its reader, who sees the number tick and a pulse on the emoji someone else just tapped.

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

  1. PostgreSQL: INSERT ... ON CONFLICT DO NOTHING
  2. PostgreSQL: unique indexes
  3. Redis: Pub/Sub
  4. MDN: Using server-sent events
  5. MDN: EventSource
  6. React: useOptimistic
  7. MDN: Request.keepalive (a POST that outlives the page)
  8. MDN: the pagehide event
  9. MDN: String.prototype.normalize (Unicode NFC)
  10. MDN: Intl.Segmenter (grapheme counting)
  11. Unicode UTS #51: Unicode Emoji
  12. MDN: IndexedDB API
  13. Cloudflare: HTTP request headers (cf-connecting-ip)
  14. Hono: the web framework this API runs on
  15. Drizzle ORM
  16. frimousse: the headless emoji picker
  17. NumberFlow: the animated number component
  18. Motion: the animation library

Related articles

Avatars from a Username

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.

How I Turned a VPS into a Platform

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.

How a Website Decides You're a Bot

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.