← All posts jacob@stephens.page
Notes · Distributed Systems

Tracking Listening Time Across Six Platforms - Without Building a Surveillance Tool

June 5, 2026 · ~750 words · Cross-device listening time via a G-Counter CRDT, with no stored timeline

Cascade is a white-noise app: it plays one waterfall recording on web, Android, macOS, Windows, iOS, and watchOS. I wanted to show people how long they'd listened, totalled across every device. The obvious implementation is a surveillance tool. Here's how I built it so that it structurally cannot be one.

One core, six shells

Cascade has an unusual shape: a single headless Rust core (cascade-core) owns all state and intent, and six thin native shells own the side effects - audio, OS integration, UI. The core has no clock, no filesystem, no network. Time is fed in: the platform sends a Tick command with the elapsed milliseconds.

That constraint was the whole trick. Because every shell already shares one definition of "what's happening," I could add listening-time accrual in one place and have it mean the same thing on all six platforms. No per-platform stopwatch.

Count audio, not intent

The naïve version counts time while the app thinks it's playing. That over-counts badly: a browser that blocks autoplay reports "playing" the instant you click, before any sound comes out. So I gated accrual on confirmed playback, not intent: a flag flipped by the platform's real "audio actually started" signal, cleared on pause or error.

// inside the pure reducer, on the existing Tick path
if tracking_enabled && audio_confirmed_playing && !muted {
    let delta = elapsed_ms.min(MAX_TICK_ACCRUAL_MS); // clamp clock jumps
    device_total_ms = device_total_ms.saturating_add(delta);
}

Note the clamp. A laptop sleeping for three hours produces one enormous "tick" on wake. Capping each tick at five seconds means a sleep/wake gap or a fiddled clock can't inflate the number. The input delta is the only attack surface a grow-only counter has, so that's where the guard goes.

The merge problem: why "latest total" is wrong

Here's the bug almost everyone ships first. You listen for two hours on your phone and one on your laptop. Both sync. If the server keeps "the latest total it received," one device silently erases the other's hours. Concurrent listening must add, not overwrite.

The clean answer is a G-Counter - a grow-only counter, one of the simplest CRDTs. Each device owns a slot and only ever increases it. The server merges with two boring operations:

-- on write: keep the higher value for this device's slot
INSERT INTO device_counters (user_id, device_id, total_ms)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, device_id)
DO UPDATE SET total_ms = GREATEST(device_counters.total_ms, EXCLUDED.total_ms);

-- on read: the lifetime total is the sum across the user's devices
SELECT COALESCE(SUM(total_ms), 0) FROM device_counters WHERE user_id = $1;

That's the entire sync engine. No vector clocks, no conflict UI, no "which version wins" - GREATEST on write, SUM on read. Two devices adding concurrently isn't a conflict; it's just addition. The device is a CRDT replica and the server is almost stupid, which is what you want a server holding other people's data to be.

The part I actually care about: it can't store a timeline

Tracking how long someone listens is one schema change away from tracking when they listen - what time they fall asleep, the shape of their week. "We promise not to look" is a weak claim, so I made it impossible to express.

The account stores an email address and one integer per device. No timestamps, no session log, no event stream. You can ask the database "how much has this person listened?" and it can answer. You cannot ask "when?" - there's nowhere for that answer to live.

This is the difference between we choose not to store your timeline and we cannot. The second is checkable: open the four-table schema and there's no column for it. It's also why I was comfortable making tracking on-by-default.

Auth without the baggage

The account exists only to add numbers across devices, so it carries as little as possible. Sign-in is an emailed magic link - no passwords to store or leak. Sessions are opaque server-side tokens, not JWTs, stored only as SHA-256 hashes. That pays off at deletion time: "log out everywhere" and "delete my account" are a single DELETE that takes effect immediately, instead of waiting out a signed token's expiry.

One subtle bug hides in every grow-only-counter design: deletion. If you delete your data but a forgotten phone sits offline in a drawer, it can later sync a stale-but-higher counter and resurrect the total you deleted. The fix is small - deleting rotates the device's id, so any late write lands in a fresh slot instead of the grave you just dug.

Where the cleverness does not go

The core never decides when to sync. It exposes one number - how much time hasn't been pushed yet - and each shell owns the cadence, because syncing depends on things only the shell knows: are we online, signed in, about to be suspended. The web shell flushes on pagehide; Android flushes in onStop. Keeping network policy out of the core lets the same pure reducer drive a watch and a desktop.

What it added up to

One Rust core, six shells, and a deliberately small Rust/Axum + Postgres service - four tables, a handful of endpoints - behind a hardened systemd unit, deployed as Terraform + Ansible. The measurement is identical everywhere because it lives in one place; concurrent devices add instead of clobbering because the merge is a CRDT; and the scariest version of the feature is off the table because the schema can't represent it.

The lesson I keep relearning: the strongest privacy guarantee isn't a policy, it's a data model that makes the bad version unbuildable. The code is on GitHub.

Drafted from my architecture notes and commit history with help from Claude. The design and the decisions are mine; the prose is collaborative.