Dev Journal
Dev Journal #2 — How the Poll Works: No Account, No Email, Just Epheme
The Problem With “Just Log In”
Most sites that want feedback from you need an account first. Sometimes it’s for deduplication. Sometimes it’s for analytics. Sometimes it’s just because the framework made it easy to add a login wall and nobody questioned it.
The poll on the Epheme homepage doesn’t do any of that. No account. No email. No cookie notice. You click a button and your vote counts — once per device per day. Here’s how that actually works.
Device Identity Without an Account
The browser side of the poll is built on `@epheme/core`, the same library that powers the Epheme Hub. The key primitive is `EphemeDevice` — a class that knows how to establish and maintain a stable device identity without requiring any form of authentication.
When the poll loads, it calls `_device.getStableId(key)`. That method follows a deliberate priority chain:
1. Existing localStorage value — if this device has voted before under this key, return that same UUID. Continuity matters: an identity that flip-flops isn’t useful.
2. Hub-registered deviceId — if the visitor has an active Epheme Hub device (loaded from IndexedDB), use that ID. The device already has a stable identity, so we should use it.
3. Generate a new UUID — if nothing else applies, create a cryptographically random UUID via `crypto.randomUUID()` and persist it to localStorage.
That priority order matters. We check localStorage before the Hub ID. This means if someone voted anonymously yesterday and then connected to a Hub today, their anonymous vote isn’t orphaned — the stored UUID takes precedence until they explicitly clear local storage.
What Actually Gets Sent
The poll client sends one of two things depending on the device state:
- If the device is Hub-registered: an `Authorization: Bearer <jwt>` header. The server verifies the JWT and extracts the device ID from it — no ID spoofing possible.
- If the device is anonymous: an `X-Device-Id` header with the stable UUID from localStorage.
In both cases, no name, no email, no IP address is stored against the vote. The device ID is a random UUID — it identifies a device for deduplication, nothing more.
Server Side: Redis With a Daily Window
On the server, each vote lands in Redis under two keys:
- `ephemeorg:votes:up:<dateStr>` and `ephemeorg:votes:down:<dateStr>` — counters for the current day, with a 25-hour TTL so they expire cleanly.
- `ephemeorg:voted:<dateStr>:<deviceId>` — a per-device flag set when a vote is cast. Also expires in 25 hours.
The TTL means counts reset daily without any cron job or cleanup task. Redis just handles it. The per-device key means each device can cast exactly one vote per day — a second request returns `409 Already voted today`.
The date string is computed in UTC so “midnight” is consistent across timezones. If Redis is unavailable, the API responds with a graceful unavailable state rather than throwing a 500 — the poll degrades politely.
Why Not Just Use Cookies?
Cookies require a notice in most jurisdictions. They travel with every request, including ones where we don’t need them. They can be scoped in confusing ways, and they’re cleared by privacy tools that users have every right to run.
<div class="callout-compromise">
<p><strong>This is a compromise we’re making consciously.</strong> localStorage is clearable, tab-scoped, and blocked by some browsers in private mode. A determined person can vote twice. We’re not pretending otherwise. The goal isn’t ironclad deduplication — it’s a reasonable signal from real visitors with minimal friction and zero data collection. For a daily poll on a dev site, that trade-off is worth it.</p>
</div>
The Library Does the Work
One thing I want to call out: the poll client in `src/vote.js` doesn’t implement any of the UUID generation or identity fallback logic. That lives in `@epheme/core/browser`. That’s intentional.
When I first wired this up, the draft used `crypto.randomUUID()` directly and manually read from IndexedDB. It worked, but it was the same pattern I’d seen in three other places across the codebase. Each one had slightly different edge cases handled slightly differently. The right move was to push that logic into the library once, test it there, and have every consumer just call `getStableId()`.
That’s the version of “build a library” that’s actually worth doing — not extracting things prematurely, but recognizing when the same subtle logic has appeared enough times that it needs a single canonical home.
No PII. Seriously.
The only data that touches the server from a vote request:
- The vote direction (`“up”` or `“down”`)
- A device UUID (random, not linked to any real-world identity)
- The current UTC date (used to compute the window key)
That’s it. Nothing is logged with device IDs attached. Nothing is sold or shared. The Redis keys expire. There is no database table with a row per vote. There is no analytics pipeline. There is no “we may use this to improve your experience.”
This is what accountless, privacy-respecting software looks like in practice. It shouldn’t be novel. But here we are.