Building Word Daily

I have been collecting words since high school and I have never once gone back and read the list. It lives in a note on my phone. Every so often I hear a good one, something like petrichor or sonder, I tap it in and feel briefly clever and then it sits there with the other two hundred and I never look at it again. The words I actually own are the ones that turned up in the middle of my day, in something I was reading anyway, at a moment when I had room for them. Not the ones I filed away for later.

For a while I figured somebody must have built the thing I wanted. A word arriving on my phone in the morning, pitched at a level I would actually find interesting, at a time I chose. Most of what I found was either a dictionary app wanting me to open it or a daily email I would archive without reading. I did not want another inbox. I wanted a nudge, once, at the right moment.

That is when it hit me. I have built push notifications before with Sydney Train Alerts and modern browsers will happily deliver a notification with no app in sight. The hard part was never delivery. The hard part is having something worth sending. So that is what I built! It is called word, daily., it is free, there is no account and the whole thing runs on Cloudflare Workers for close to nothing. Here is how it fits together.

Web push with no app to install

The delivery decision came first. A native app means two app stores, review queues and convincing someone to install software for one buzz a day. Web push skips the lot. Your browser already has a push channel sitting there. A small service worker subscribes through the Push API, the browser hands back an endpoint URL and a pair of keys and if you post an encrypted message to that endpoint the phone buzzes. Screen locked, browser closed, does not matter. I still reckon that is a bit magic.

I wrote the sending side by hand with WebCrypto rather than pulling in a push library, because I wanted to properly understand it. Two RFCs do the work. RFC 8292, better known as VAPID, has your server sign a short JWT with an elliptic curve key so the push service knows who is knocking. RFC 8291 encrypts the payload to the subscription's own keys so nobody in the middle can read the notification, including the push service carrying it. WebCrypto on Workers gives you every primitive you need. ECDH gets the shared secret, HKDF stretches it into keys and AES-GCM seals the payload. It is a few hundred lines and it was easily my favourite part of the build.

One Durable Object per subscriber

Then there is timing. Everyone picks their own hour, their own minute and their own timezone and the whole promise of the thing is that it lands when they said. The obvious approach is a cron that wakes up every minute, scans everyone and works out who is due. That works until it does not and it scans the entire table sixty times an hour to find the handful of people who matter.

Cloudflare Durable Objects have a better answer. Each subscriber gets their own object and that object holds one durable alarm set to their next delivery. Nothing scans anything. The alarm fires, the object sends that person's word, works out the next matching time in their timezone and re-arms itself. It is asleep the rest of the time and costs nothing while it sleeps.

The bit I want to flag for anyone building something similar is the failure mode. A Durable Object can lose its alarm. It is rare and it is recoverable, but if you have no way to notice, that subscriber goes quiet forever and nobody finds out. So D1 stays the source of truth for every subscription and an hourly cron walks it and re-arms anything whose alarm has gone missing. The object owns the schedule, the database owns the fact that the subscription exists and either one can rebuild from the other.

The shared ledger, which is the whole economic model

Words come from Workers AI. The naive version generates a word per subscriber per day and that version is a trap. At ten thousand users it works out to somewhere around a thousand dollars a year, for content that is largely identical, because two people who both asked for advanced words got two separate model calls to produce two different advanced words on the same morning.

Instead there is one table keyed on field, level and day. Everyone who wants the same kind of word on the same day reads the same row. Generation scales with the number of distinct streams rather than the number of people, so the difference between one subscriber and ten thousand is a rounding error. Words are generated about a week ahead into that ledger so nothing has to happen at delivery time and the buffer holds nine days rather than seven because subscribers span UTC minus eleven to UTC plus fourteen and that is a twenty five hour spread. There is always someone for whom it is already tomorrow.

The model matters more than the prompt

Here is the bit I got wrong and I think it is the most useful thing in this whole post.

I started on an 8B model, the smallest and fastest one available, with eighteen hand-picked example words per level to anchor it. That works most of the time. Then I opened the site one morning and the obscure word of the day was clap, defined as a sudden loud noise. Not obscure. Not close to obscure.

My first instinct was to write a denylist of common words and reject anything on it. So I did and then I audited the whole nine day buffer against it and it caught two words out of about a dozen bad ones. Nostalgia sailed through. So did bewilderment and childish and enormous. They are common words that simply are not in the first thousand and a list long enough to catch them would still only ever have produced words that are not-common rather than words worth reading.

The real problem was that I was asking an 8B model to make a fine judgement about difficulty and that is exactly the kind of task that gets better with scale rather than with better prompting. Moving to a 70B model fixed it properly. The denylist stayed as a cheap floor, because it costs nothing and it catches the worst, but it was never going to be the answer on its own.

What I ended up with

I am genuinely pleased with how small it is. One Worker, one D1 database, a Durable Object per person and no client framework anywhere. The whole site is server rendered HTML with about three hundred lines of vanilla JavaScript for the subscribe flow. It costs me effectively nothing to run and it does the one thing I wanted, which is put a good word in front of me in the morning without asking me to open anything.

If you are building on Workers and reaching for Durable Objects, the practical tip I would pass on is to decide early which store is the source of truth and write it down somewhere the next person will see. I kept D1 authoritative and treated every Durable Object as rebuildable from it and that single decision is what made the lost-alarm problem a recoverable annoyance instead of a silent data loss bug. Work out your recovery path while the system is still small enough to hold in your head.

If you want the thing itself, pick a level and a time on the homepage and it will start turning up. I have also written about why the words you look up never stick and why a word from your own field lands differently. Happy coding!

All posts