How I count a fair ad view: IntersectionObserver, sendBeacon and server-side dedupe
I run a small cross-promo network for apps built with AI. Each member puts a little card on their site that shows other members' apps, and every view earns them a token. That means the view counter is the economy. If it
I run a small cross-promo network for apps built with AI. Each member puts a little card on their site that shows other members' apps, and every view earns them a token. That means the view counter is the economy. If it counts too much, people farm tokens. If it counts too little, hosts feel cheated.
Here's how the embed decides what a "view" is, in about 40 lines of plain JavaScript.
1. Only count what people can actually see
A card at the bottom of a long page that nobody scrolls to shouldn't earn anything. IntersectionObserver tells us when the card is on screen, and a timer makes sure it stays there:
var sent = false, timer = null;
function send() {
if (sent) return; sent = true;
var u = origin + "/api/view?imp=" + encodeURIComponent(ad.imp);
if (navigator.sendBeacon) navigator.sendBeacon(u);
else fetch(u, { method: "POST", keepalive: true });
}
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting && e.intersectionRatio >= 0.5) {
if (!timer) timer = setTimeout(function () { send(); io.disconnect(); }, 1000);
} else if (timer) {
clearTimeout(timer); timer = null; // scrolled away too fast: no view
}
});
}, { threshold: [0, 0.5, 1] });
io.observe(host);
The rule is at least 50% visible for 1 continuous second. Scrolling past quickly cancels the timer.
A few details that matter:
-
threshold: [0, 0.5, 1]makes the callback fire when the card crosses 50%, not just when it enters or leaves. -
sendBeaconsurvives the user closing the tab right after the second ends.fetch(..., { keepalive: true })is the fallback. -
io.disconnect()after sending means one view per page load, whatever the visitor does. - Every ad response carries a one-time impression id (
imp), so the server can reject a replayed beacon.
2. Dedupe on the server
The client can't be trusted, so the server only pays one view per visitor per slot every 30 minutes. Refreshing the page ten times earns once. A view also only pays when the card is on another member's site. Showing your own app on your own site earns nothing.
3. Work in React and other SPAs
Most apps built with Lovable, Bolt or v0 are single-page React apps. The placeholder <div data-vibe-slot> often appears after the script has loaded, so the embed watches the DOM:
var pending = false;
new MutationObserver(function () {
if (pending) return; pending = true;
setTimeout(function () { pending = false; boot(); }, 50); // batch bursts of mutations
}).observe(document.body, { childList: true, subtree: true });
boot() is idempotent: it mounts any placeholder that doesn't have a card yet and skips the rest. The 50 ms batching keeps a React re-render storm from calling it hundreds of times.
4. Keep big balances from taking over
Which app gets shown is a weighted random pick. If the weight were your token balance, the earliest members would take every slot. So it's the square root of the balance (capped), and new members get a 2Γ boost for their first 25 views so they see results on day one.
Try it
The whole thing is one script tag:
<script async src="https://vibeswap.dev/embed.js" data-slot="YOUR_SLOT_ID"></script>
If you've built something with AI and want more people to see it, it's free at vibeswap.dev. You start with 100 tokens. I'd love feedback on the counting rules, especially if you can think of a way to game them.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.