← Binderdex dev log

2026-10-18

You can finally trust your own funnel

productmobilegrowth

The funnel we couldn't trust

For two weeks, every activation number in Binderdex was an opinion wearing a lab coat. The day-zero read found the first-card milestone firing on barely half the card adds that should have triggered it, and the ten-card milestone doing worse. When two events describing the same action disagree that badly, you stop quoting either one.

The empty-cache trap

The bug lived in a reasonable-looking guard. To count a member's cards before an add, we read the local query cache. A brand-new member has no cache yet, so the previous count was undefined, and the code quietly skipped the milestone. Which is exactly when the milestone matters most: their very first card.

The fix flips the logic. When the cache is empty, the server's post-add count stands in, and the add is assumed to have created a distinct card. That assumption can only misfire on a duplicate add landing exactly at a threshold, and a once-per-member flag bounds the damage to one extra event.

Here's the shape of the fix, rewritten for illustration:

// One add, one honest before/after. No cache? Ask the server.
const counts = await addedCardDistinctCounts({
  cachedPreviousIds: cache?.cardIds,      // null for a brand-new member
  cardId,
  serverDistinctCards: () => fetchCollectionSize(),
});
emitCrossedMilestones(counts.previous, counts.next);

One emitter, enforced by the build

Milestones also disagreed across code paths. Now a card add becomes analytics in exactly one place: the canonical add event first, milestones second, same source, same account. A static test fails the build if any other file emits an activation event, so the pairing is structural, not remembered.

  • Honest permission status: the opt-in sheet no longer reports a denial the member never made.
  • Shown means shown: the explainer event fires from the sheet's own visible callback, so a prompt queued behind an abort never counts as shown.

The simulator check was pleasantly boring: force the cache unloaded, add a card, get exactly one first-card event, add again, get zero. Device runs for the ten-card edge are still unproven.

The takeaway

If your funnel reads low and you can't find the bug, stop auditing the query. Audit whether the events fire where you think they do, and let one component own every derived event. Numbers should be boring. Boring means they're real.

this is the build log of binderdex · www.binderdex.com · all entries · essays