← All posts
March 20, 20265 min readFeedbackIQ team

Upvote routing: when duplicates exist, the parent wins

Toggle semantics, duplicate-aware target ID, optimistic updates, localStorage-backed voter state, and the tiny invariant that keeps the public roadmap sane.

Upvote routing: when duplicates exist, the parent wins

Once you have a dedupe layer, upvotes get interesting fast. A user upvotes an item that is secretly a confirmed duplicate of another item. Where does the vote go? If it stays on the child, the parent’s count is wrong. If the UI reveals the child is a duplicate, every roadmap becomes a tangle of arrows. The right answer is: the child is invisible to the user, and their vote silently routes to the parent.

The invariant

Feedback items can be in three states relative to duplicates:

  • StandaloneduplicateOfId is null. Treat normally.
  • Suggested duplicate duplicateOfId is set but duplicateConfirmed is false. Still displayed as a separate item on the roadmap; the dashboard shows a “Possible duplicate” card for review.
  • Confirmed duplicate duplicateConfirmed is true. Hidden from the roadmap; votes route to the parent.

All three states flow through the same upvote endpoint. The endpoint is responsible for picking the right target:

const feedback = await prisma.feedback.findUnique({
  where: { id: feedbackId },
  select: { id: true, duplicateOfId: true, duplicateConfirmed: true },
});

const targetId =
  feedback.duplicateConfirmed && feedback.duplicateOfId
    ? feedback.duplicateOfId
    : feedback.id;

Every write (create upvote, delete upvote, increment upvoteCount) uses targetId, not feedbackId. It’s a one-line change that makes the invariant hold no matter where the vote originated.

The toggle, not a one-way click

The first version was upvote-only. Users who tapped twice got a duplicate-vote error. That’s bad UX for a widget that’s supposed to feel weightless. We switched to a toggle: POST adds the vote, DELETE removes it, both return the new count. The button knows its own state from localStorage keyed by feedback ID + a voter hash.

The client is optimistic — it updates the count and the filled state before the network call resolves, then rolls back if the API returns an error. You can mash the upvote button repeatedly and the UI stays consistent because there’s a busy flag that gates concurrent requests.

async function handleClick() {
  if (busy) return;
  setBusy(true);

  const wasVoted = voted;
  const nextVoted = !wasVoted;
  setCount(wasVoted ? Math.max(0, count - 1) : count + 1);
  setVoted(nextVoted);

  const res = await fetch(`/api/v1/feedback/${id}/upvote`, {
    method: nextVoted ? "POST" : "DELETE",
  });

  if (!res.ok) {
    setCount(count);       // rollback
    setVoted(wasVoted);
  }
  setBusy(false);
}

Who counts as a voter

The widget is embedded on sites where most visitors are unauthenticated. We identify voters with a stable hash of (project ID + a long-lived cookie) — enough to prevent a single user from racking up hundreds of votes, not enough to identify them personally. No emails collected, no accounts required.

The FeedbackUpvote table has a unique constraint on(feedbackId, voterHash), so an accidental double-POST is idempotent at the DB level. Belt and suspenders.

When a duplicate gets un-merged

Sometimes the dashboard user looks at a suggested duplicate and clicks “not a duplicate.” Sometimes they’ve already confirmed one and change their mind. The un-merge path has to reverse the vote transfer cleanly: we remember the count the child had at merge time (its own upvoteCount + 1 for the original vote that caused the merge), and subtract that from the parent (clamped to zero) on unmerge.

It’s not perfect — subsequent upvotes while merged can’t be un-transferred cleanly without a full event log — but it’s correct for the common case and close enough for the edge case that nobody notices.

What we got wrong

The first version of the toggle disabled the button after an upvote — disabled={voted || busy}. That meant users who accidentally upvoted couldn’t undo, which they correctly complained about. Now it’s just disabled={busy}, and the filled/unfilled state communicates what clicking will do.

Next up: the changelog. Once a PR merges, we turn it into a public changelog entry, email the upvoters, and syndicate an RSS feed — all automatically.

Try FeedbackIQ

Drop a widget on your site, ship PRs from feedback

Claude reads the report, writes the fix, opens the PR on your repo. Dedupe with pgvector so the backlog doesn’t drown in duplicates.

Start for free