🫢 lazydev

Create persistent like button with Cloudflare Workers

I added a fancy like button on my blog (it’s here to the right if you are on desktop size screen or at the bottom on mobile). Apart from having visits and page views analytics, a like button is a good way to measure content engagement.

BUT, a single like is not fun, why not give people an option to hit the button multiple times, so I could have fine-grained engagement analytics? Now, as a visitor I wouldn’t care to press the button more than once, unless it’s a fun activity, which means that readers should be given an incentive. A good way to do so is to spice the button with a bit of game theory, by adding progressing visuals and sound. Try to press the button multiple times now and notice how the icon is different after every hit while also resembling a progress bar. And the sound gets a higher pitch with every hit, which builds up tension and subconsciously creates an expectation of reaching a summit at some point (have you been to techno parties?). You do get to a summit after 16 likes, which is signalled with a different sound and a wiggling animation.

The data suggests that gamification works! After the button is pressed once, there’s no way back.

Alright, now onto the coding part. I was lazy enough to not care about the proper way of doing it, so instead I went with whatever got first on my mind. Today it happened to be Cloudflare Workers.

CF Workers are basically highly distributed lambdas with a bucket of integrated services. One of them is an eventually consistent key-value store that caches frequent reads at the edge, which is pretty cool. One caveat though — kv store doesn’t have atomic writes. Which means when a blog post goes viral and visitors start hitting the button a lot there’s a high chance that some writes will be lost, which is fine for me. But we’ll still look into that later here. Also in case of high traffic it’s possible to exceed the limit of a free plan, so you’ll have to start paying $5 a month for 1M writes and 10M reads. Don’t forget about value size limit, which is 25MB per value in a store.

Here’s a simplified version of a worker. Of course, you’ll have to handle CORS and add a couple of checks for additional security, but the general idea is described down here:

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (url.pathname === "/love-it") {
      const id = url.searchParams.get("aid"); // article id
      const ip = request.headers.get("cf-connecting-ip"); // client ip

      // get all ip->likes for a given article
      const ipToLikes = await env.LIKES_KV.get(id, { type: "json" });

      // +1
      ipToLikes[ip]++;

      // overall likes count for the article
      const nOfAllLikes = Object.values(ipToLikes).reduce(
        (a, b) => a[1] + b[1]
      );

      // write the data back
      await env.LIKES_KV.put(id, JSON.stringify(ipToLikes));

      // reply with count for the given ip and overall count
      const body = JSON.stringify({
        user: ipToLikes[ip],
        all: nOfAllLikes,
      });

      return new Response(body);
    }
    return new Response(null, { status: 404 });
  },
};

Atomicity can be hacked with compare-and-swap, but eventually it has to give up because worker’s CPU time is limited (10ms on free plan and 50ms on a paid one). I’m pretty sure this is enough time for my blog.

async function cas(store, key, retries, f) {
  const value = await store.get(key, { type: "json" });
  const nextValue = f(value); // update

  // the value in the db hasn’t changed — upsert an updated one
  // `isEqual` is a deep equals function here
  if (isEqual(value, await store.get(key, { type: "json" }))) {
    return store.put(key, JSON.stringify(nextValue));
  } else if (retries === 0) {
    // no retries left, YOLO
    return store.put(key, JSON.stringify(nextValue));
  } else {
    // otherwise retry
    return cas(store, key, retries - 1, f);
  }
}

cas(env.LIKES_KV, id, 5, (ipToLikes) => {
  ipToLikes[ip]++;
  return ipToLikes;
});

I warn you, the above cas is not tested, that’s just an idea.

The client side code is pretty boring, except maybe the audio part where the pitch should get higher with every next button press.

const ctx = new AudioContext();
const gain = ctx.createGain();
gain.gain.value = 0.5; // drop the volume, your readers will thank you

function loadAudio(url) {
  return fetch(url)
    .then((r) => r.arrayBuffer())
    .then((buff) => ctx.decodeAudioData(buff));
}

function playAudio(abuff, pitch) {
  // Web Audio API requires a new buffer source
  // every time you wanna play a sound
  const source = ctx.createBufferSource();
  source.buffer = abuff;
  source.connect(gain);
  gain.connect(ctx.destination);

  // here’s where pitch is applied
  source.playbackRate.value = pitch;
  source.start(0);
}

let pitch = 1;

button.addEventListener("click", () => {
  if (ctx.state === "suspended") {
    // resume audio context
    // because it gets blocked when created before
    // the first user interaction
    ctx.resume();
  }

  // bump pitch on every click
  pitch += 0.1;
  loadAudio(url).then((abuff) => playAudio(abuff, pitch));
});

AI-generated conclusion

In conclusion, Cloudflare Workers can be used to create a persistent like button with gamification features, and while there may be some limitations with the key-value store, it is still a viable option for small to medium-sized websites.