I Built a Chrome Extension That Tells Jokes (4,600 People Use It)

How a simple joke extension became a TypeScript learning project, a monetization experiment, and somehow one of the more successful things I've shipped.

· 6 min read
I Built a Chrome Extension That Tells Jokes (4,600 People Use It)

I wanted a Chrome extension that would tell me a joke when I opened a new tab or clicked the icon. Something clean, fast, and actually funny. I looked around. The ones I found were slow, ugly, or pulling from joke APIs with no content filtering. So I built my own.

That was the whole origin story. No grand vision. No market research. I wanted a thing, the existing things weren’t good, so I made the thing.

What it does

Joke of the Day is a Chrome extension (Manifest V3) that serves you one joke per day. Click the icon, you get a joke: a setup and a punchline. Free users get a new joke every 12 hours, plus one free skip per cycle if the joke isn’t landing. Premium users ($3.99, one-time) get unlimited skips.

It has 4.6k installs and a 4.6 rating on the Chrome Web Store. I did zero marketing. It just… grew.

The stack

The first version was plain JavaScript with a build script held together with string. It worked but was annoying to extend. When I wanted to add the premium tier and a proper joke engine, I rewrote it as a learning exercise: TypeScript 5.x, Vite with the CRXJS plugin, strict mode on.

CRXJS is worth calling out specifically. It handles the annoying parts of Chrome extension development: hot module reload during development, manifest validation, bundling the service worker and popup as separate entry points. Without it, MV3 development involves a lot of manual reloading and guessing why the service worker died.

For payments I used ExtensionPay. No Stripe integration to maintain, no webhook server, no subscription management UI. The SDK is a single import. Under MV3 you have to call startBackground() in the service worker as well as the popup, because the service worker can be killed at any time and needs to reinitialize payment state when it wakes up:

// service-worker.ts
chrome.runtime.onInstalled.addListener(async () => {
  if (EXTENSIONPAY_ID) {
    ExtPay(EXTENSIONPAY_ID).startBackground();
  }
  chrome.alarms.create(ALARM_NAME, { periodInMinutes: 30 });
  await storage.getUserState();
});

// Also runs on every service worker startup, not just install.
if (EXTENSIONPAY_ID) {
  ExtPay(EXTENSIONPAY_ID).startBackground();
}

That second startBackground() call outside the listener is not a bug. Service workers under MV3 are ephemeral: they start, do their job, and shut down. The onInstalled handler only fires once. Every other time the worker wakes up, you need that top-level call.

The joke engine

This is the part I spent the most time on, and the part I’m most happy with.

The extension has three tiers of jokes, served in alternating order. First it works through 500 curated monthly jokes (seasonal, tied to the current month). Then it alternates with a general pool. When both curated pools are exhausted, it falls through to an infinite generator.

The alternating logic sits in getCurrentJoke():

private getCurrentJoke(): Joke {
  const totalSeen = this.state.currentMonthJoke + this.state.currentJoke;
  const preferMonthly = totalSeen % 2 === 0;

  const hasMonthly = this.state.currentMonthJoke < monthlyJokes.length;
  const hasGeneral  = this.state.currentJoke     < generalJokes.length;

  if (preferMonthly) {
    if (hasMonthly) return monthlyJokes[this.state.currentMonthJoke];
    if (hasGeneral)  return generalJokes[this.state.currentJoke];
  } else {
    if (hasGeneral)  return generalJokes[this.state.currentJoke];
    if (hasMonthly)  return monthlyJokes[this.state.currentMonthJoke];
  }

  // Both curated pools exhausted — use the generator.
  return this.getGeneratedJoke();
}

Even if someone used the extension every single day for years, they’d never hit a wall. The fallthrough to the generator is seamless.

The generator itself is template-based. It has a set of joke structures (templates with named slots) and word pools keyed by slot name. Each month has overrides that weight the pools toward seasonal vocabulary:

const MONTH_OVERRIDES: Record<number, Partial<Pools>> = {
  9: { // October
    holidayThing: ["a ghost", "a witch", "a pumpkin"],
    seasonThing:  ["pumpkins", "costumes", "candy", "spooky sounds"],
    punNoun:      ["broommates", "a boo-tiful idea", "a fright delight"],
    punchline:    ["was having a spook-tacular time", "didn't want to be a scaredy-cat"],
  },
  // ... one entry per month
};

The mergePools function combines base and override by prepending seasonal words, so they appear more often than their base counterparts without completely replacing them. And the fill() function slots words into templates using a regex replace:

function fill(str: string, pools: Pools) {
  return str.replace(/\{(\w+)\}/g, (_, key: keyof Pools) => pick(pools[key]));
}

One line. Takes "Why did the {thing} {verb}?" and turns it into "Why did the snowman start a band?". The deduplication uses a normalized key so near-identical jokes don’t slip through on different runs:

function normalizeKey(j: Joke) {
  return `${j.month ?? "x"}|${j.question.trim().toLowerCase()}|${j.answer.trim().toLowerCase()}`;
}

The badge problem

MV3 killed persistent background pages. That was fine for most of what I needed, but it created a specific problem: I wanted to show a “NEW” badge on the extension icon when a joke became available. Under MV2, you’d just have a background page listening to a timer. Under MV3, the service worker is gone the moment it goes idle.

The fix is Chrome’s Alarms API. You register an alarm, and Chrome will wake your service worker on a schedule even if it’s been shut down:

chrome.alarms.create(ALARM_NAME, { periodInMinutes: 30 });

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === ALARM_NAME) {
    await checkAndNotifyNewJoke();
  }
});

Every 30 minutes Chrome wakes the worker, it checks if a new joke is available, and either sets the badge or clears it. The worker then shuts down again. The key insight: you’re not keeping the worker alive. You’re scheduling wake-ups.

What surprised me

4,600 installs with no marketing. No Product Hunt launch, no Reddit post, no Twitter thread. Just the Chrome Web Store listing and an SEO-friendly title.

I don’t fully understand it. My best guess: the search term “joke of the day extension” has low competition and the listing copy is specific enough to rank. Whatever the reason, it’s the most passive thing I’ve ever shipped.

The 4.6 rating is actually what I’m most proud of. Extensions are hard to rate well because users install them, forget about them, and only bother to review when something goes wrong. A 4.6 on ~50 reviews means people actively liked it enough to say so.

Where it is now

It’s live at the Chrome Web Store. The premium tier is $3.99 one-time, which is probably too cheap, but it converts and I don’t want to think about billing support.

The codebase is TypeScript throughout, which was the whole point of the rewrite. Strict mode caught several bugs that would have shipped silently in the original JS version.

What I’d do differently

Start with TypeScript. The rewrite wasn’t painful but it was unnecessary. Strict TypeScript from the start would have caught the same bugs earlier.

Pick a more defensible content niche. “Jokes” is broad. A version scoped to a specific style (dry humor, dad jokes only, one-liners) would be easier to market and would attract more loyal users.

Add telemetry earlier. I have no idea which jokes users see most, which ones get skipped, or how many people actually exhaust the curated pool. The storage layer exists; adding basic anonymous analytics would have taken a day and would have made the joke generator much easier to tune.

If you’re thinking about building a Chrome extension, the tooling has gotten genuinely good. TypeScript + Vite + CRXJS is a fast setup and MV3’s constraints are manageable once you internalize the service worker lifecycle. Start small. Something you’d use yourself is the right place to begin.

Built as part of

View the project →