Add new-article push notifications to a static Astro site: a Web Push design with Cloudflare Workers, D1, and Queues

Translated from the original Traditional Chinese version.

Summary

An Astro static site does not need server-side rendering (SSR) to send new-article notifications. Deploy a separate Cloudflare Worker with a D1 database and a Queue, and use Web Push. The site outputs a Service Worker and an article list, while the Worker manages subscriptions, decides which articles are new, and sends notifications at 20:00 every day. On the free plan this supports about 3,300 devices, but it does not guarantee that every notification arrives and does not resend failures.

Table of contents

To add new-article push notifications to an Astro static site, the site itself does not need to become server-side rendered (SSR). At build time the site outputs one extra Service Worker and one article list, and a separate Cloudflare Worker manages subscriptions and sends the notifications. When readers browse articles, they never call this Worker.

The “Get notified of new articles” link that this site’s home page used to have was built with this design. The feature was removed in November 2026. The browser asks whether the site may send notifications only after a reader clicks the link.

The screenshot below is the Chinese home page before the removal. The link there is labeled 訂閱新文章通知 (“Get notified of new articles”), and the link next to it is 用 RSS 追蹤新文章 (“Follow new articles with RSS”).

Before the removal, on the right of the category bar on the home page are two links: one to subscribe to new-article notifications and one to follow new articles with RSS
In Chrome, for example, a window appears below the address bar after the click, saying “vervecode.dev wants to: Show notifications”.

The screenshot uses Chrome’s Traditional Chinese interface. The window title is vervecode.dev 要求下列權限 (“vervecode.dev is requesting the following permission”), the item is 顯示通知 (Show notifications), and the buttons are 允許 (Allow) and 封鎖 (Block). The page link has changed to 正在設定通知… (“Setting up notifications…”).

Chrome’s permission window saying vervecode.dev wants to show notifications, with Allow and Block buttons below it and the page link changed to a setting-up message
After the reader clicks Allow, they get one system notification at 20:00 on any day with a new article, and clicking it opens the article. On Windows, Chrome hands the notification to the system to display, and it stays in the notification center afterward. The screenshot below shows a notification sent during local testing, which is why the source reads 127.0.0.1:4321.

The screenshot uses the Traditional Chinese Windows interface. The notification title is the title of a Chinese article about Bedrock Edition shader packs, the body VerveCode 新文章 means “VerveCode new article”, and the source is 127.0.0.1:4321.

The Windows notification center showing a Google Chrome notification titled with a Chinese article title about Bedrock Edition shader packs, with the body VerveCode new article and the source 127.0.0.1:4321
These three screens are all a reader actually sees. Every other step happens automatically between the site and the Worker.

Design

All the mechanisms below follow these rules, which I set for this site:

  • The browser asks for notification permission only after the reader clicks the link, and never when the reader enters the site.
  • At most one notification is sent per day, at 20:00 Taiwan time. From publishing an article to a reader receiving the notification, the wait can be up to about one day.
  • Each device gets at most one delivery attempt per night. A failed or timed-out delivery is never resent.
  • Only Cloudflare’s free plan is used. When the day’s quota runs out, sending stops and resumes the next day.

Architecture

The site and the push feature are deployed as two Workers. The site Worker contains only static files, so browsing costs nothing and does not count toward Worker requests. The push Worker runs only when a reader subscribes, when the schedule fires, and when notifications are sent, so no amount of article traffic uses up the push quota.

Architecture diagram: the reader’s browser browses articles on the site and subscribes through the push Worker, the push Worker reads the site’s push-manifest.json every hour, and the Queue consumer sends through the browser push service to the Service Worker, which shows the notification

Subscription

When a page loads, it only checks the browser’s own subscription state. The Turnstile human check loads only after the reader clicks the link and allows notifications. After the subscription request reaches the Worker, it passes through several checks in order, and only when all of them pass is it written to the D1 database.

Subscription flow: the page asks for permission, gets a token from Turnstile, creates the browser subscription, and POSTs it to the push Worker, which checks WAF, Origin, rate limit, endpoint allowlist, and Siteverify before it upserts into D1
owner is a random value the browser generates at subscription time, and D1 stores only its hash. The Worker compares this value whenever the subscription is updated or canceled. Even if someone knows the push URL (endpoint) of a device, they cannot change or cancel another person’s subscription without the matching owner.

From publishing to notification

The site only lists all published articles, and the Worker decides which ones are new. The Worker checks the article list once an hour and records new articles in the database. At 20:00 each day, it merges the articles recorded in the past 24 hours into one notification.

Flow from publishing to notification: every hour, articles published within 3 days, not yet recorded, at most 10 in the same round, and whose pages return 200 are picked from the manifest and recorded in articles. At 20:00 every day they are merged into one notification, split into pages of 99 devices that go into the Queue, and the consumer writes a delivery record before sending to the push service
Accepting only articles published within 3 days means that, on the first launch or after recovering from downtime, old articles are not all pushed again. If more than 10 new articles show up in one round, none of them are recorded, which stops a bulk move of articles or a wrongly entered date. This way one run sends at most 1 list request plus 10 article page requests, which is below the free plan’s limit of 50 requests per run. These filter rules look only at the URL and the publish time. If an old article gets a new URL and a recent publish time at the same time, it is still treated as a new article and triggers a notification.

“At most one delivery per device per night” is enforced by three levels of primary keys in the database. The notification content for each day uses the date as its primary key, so only one exists per day. Before each page of the subscriber list goes into the queue, a record is written first, so the same page is not processed twice. Before it really sends, each device also gets a delivery record. Cloudflare Queues guarantees that each message is delivered at least once, so the same message may be processed twice, and on the second pass the Worker finds the delivery record already exists and skips it. The cost of this approach is that if the program stops after writing the record but before sending, that device receives no notification that night.

Free-plan capacity

The free Workers plan limits CPU time to 10 ms per run. The Web Push encryption done before sending must be done device by device and is the most CPU-heavy work in the consumer. In production, handling 4 devices in one run took 30 ms. So in this design each Queue message maps to one device, and each consumer run encrypts and sends exactly one notification.

When each message goes to one device, the number of messages grows with the number of devices, and the Queue quota becomes the second bottleneck. The free Queues plan allows 10,000 operations per day, and the write, read, and delete of each message count as one operation each:

1messages ≈ N (devices) + ⌈N / 99⌉ − 1 (paging)
2Queue operations ≈ 3 × messages
3N = 1,000 → about 3,030 operations
4limit: 3 × (N + N / 99) ≤ 10,000 → N ≈ 3,300 devices

According to the Workers limits and Workers pricing pages as of 2026-10-04, Workers get 100,000 requests per day and D1 gets 100,000 row writes per day on the free plan, which are both plenty at 3,300 devices. When subscribed devices exceed about 3,300, that night’s sending stops once the Queue quota runs out and, by design, is not resumed. At that scale, move to the Workers Paid plan so that one message can be sent to several devices.

Implementation details

Public settings on the site

Web Push needs a pair of VAPID keys, which you can generate with npx web-push generate-vapid-keys. Prepare one pair for the local environment and one for production. For Turnstile, create a widget in the Cloudflare dashboard and allow only the production site’s domain. Private keys and other secrets live only in the push Worker. The site needs only the public settings for these three environments:

Environment Subscribe link API address VAPID public key Turnstile sitekey
Local (astro dev) Shown Address of the local wrangler dev Public key for local use Cloudflare’s test sitekey
Preview (non-main branch) Hidden Not needed Not needed Not needed
Production Shown Production URL of the push Worker Public key for production Sitekey of the production widget

At build time, the subscribe component picks one set for the environment and outputs it as data-* attributes in the HTML. Workers Builds provides the WORKERS_CI_BRANCH environment variable during builds, and the code uses it to tell whether this is a preview branch, so the production database never receives subscriptions created from a preview URL.

Article list

The article list is a static file /push-manifest.json generated at build time. It holds an articles array that lists only articles that are published and whose publish time has arrived. Each article has four fields:

Field Content
id The SHA-256 hash of the article URL, formatted as a UUID, so you do not need to maintain it separately in front matter
url The final path, starting and ending with /, with no query string and no further redirect
title The article title, up to 200 characters
published The publish time in ISO 8601 format

The Worker does not follow redirects when it checks whether an article is live, so an article whose url does not match its real path is never recorded as a new article.

Frontend subscribe and unsubscribe flow

When a page loads, the script only reads the browser’s current push subscription and decides whether the link says subscribe or unsubscribe. After a click to subscribe, it runs these steps in order. If any step fails, it stops and shows the error message in place:

  1. Call Notification.requestPermission(). Browsers allow asking for notification permission only at the moment of a user action, so this step must come before any asynchronous work.
  2. Load Turnstile dynamically, get a one-time token with action: push_subscribe, and remove the widget right after.
  3. Read owner from localStorage. If it is missing, generate 32 random bytes and store them.
  4. Register /push-sw.js with scope /, and create the subscription with the VAPID public key (userVisibleOnly: true). If the browser already has a subscription but owner is lost, cancel the old subscription first and create a new one.
  5. POST endpoint, p256dh, auth, owner, and the token to /subscribe. If the write fails, revoke the subscription that was just created, so that the page does not show “subscribed” while the database has no record.

To unsubscribe, POST the same fields (no token needed) to /unsubscribe, and then cancel the browser subscription.

On iOS and iPadOS 16.4 and later, a reader must add the site to the Home Screen before the site can ask for notification permission. The site needs to provide a manifest.webmanifest with display: standalone. On iOS, when the site is not opened from the Home Screen, clicking the link shows only a prompt to add it to the Home Screen.

Service Worker

/push-sw.js handles only push. It has no fetch handler and caches no articles. This is the notification content the push Worker sends:

Field One new article Several new articles
id Notification date YYYY-MM-DD Same
title The article title A summary of the number of new articles, “N new articles”
body A fixed description text Titles of the first 3 articles
path The article URL /
expires End of that day’s sending window Same

These are the checks after a push arrives and the behavior after a click:

Service Worker flow: the push event parses the JSON, checks the fields, checks the path, and claims the notification ID in IndexedDB, and the notification is not shown if any step fails. The notificationclick event closes the notification and gets a same-origin URL, then focuses a window with that URL if there is one and otherwise calls openWindow

Data model

The push Worker keeps subscriptions, articles, and delivery state in six tables:

ER diagram of the D1 data model: subscriptions and digests each relate to zero or more deliveries, digests relate to zero or more outbox rows and articles, and control is a global setting with a single row
deliveries uses “notification date + subscription ID” as a composite primary key, which is what enforces at most one delivery per device per night. control.enabled is the master switch (kill switch) of the whole push feature. It defaults to 0, and every step of the sending flow checks it first.

When the rows read or written per day exceed the quota, free-plan D1 rejects queries, so every query needs an index to avoid scanning the whole table:

Table Indexed columns Purpose
subscriptions active, id Read active subscriptions page by page in ID order
subscriptions active, updated_at Clean up old inactive subscriptions
articles digest_id, accepted_at Find new articles that have not been sent yet
digests expires Clean up expired notifications
outbox digest_id Clean up expired sending jobs
deliveries subscription_id Check that no delivery record exists before cleaning up a subscription

Protecting the subscription API

The subscription API is a public endpoint that anyone can call. The checks in the table below are listed in execution order, and a request stopped by an earlier check never reaches the later ones.

Check What it does
WAF custom rule The push domain allows only the subscribe, unsubscribe, health check, and admin paths
WAF rate limit Requests starting with /push-api/ are limited to 10 per IP per 10 seconds, stopped before the Worker runs
Cloudflare Access Admin paths require Service Auth, and the Worker also compares ADMIN_TOKEN
Custom domain only workers_dev and preview_urls are turned off, so the WAF cannot be bypassed
Origin Only sites listed in ALLOWED_ORIGINS are accepted
Rate Limiting binding At most 10 per IP per minute, to protect D1 writes
Request format Accepts only JSON up to 16 KB, and the length and format of owner and the encryption keys must be correct
Endpoint allowlist Accepts only HTTPS URLs of the FCM, Mozilla, and Apple push services, so the Worker cannot be used to send requests to arbitrary URLs
Turnstile At subscription time, Siteverify confirms that success is true, hostname equals the request origin, and action equals push_subscribe

The subscription id is the hash of the endpoint. The write uses a single upsert, and the owner comparison is in ON CONFLICT ... WHERE. When ownership does not match, no data is updated and the API returns 409:

1INSERT INTO subscriptions(id,endpoint,p256dh,auth,owner_hash,created_at,updated_at)
2VALUES(?,?,?,?,?,?,?)
3ON CONFLICT(id) DO UPDATE SET p256dh=excluded.p256dh, auth=excluded.auth, active=1,
4  created_at=excluded.created_at, updated_at=excluded.updated_at
5WHERE subscriptions.owner_hash = excluded.owner_hash;

Unsubscribing sets the row whose id and owner_hash both match to inactive.

Recording new articles every hour

The cron 30 * * * * records new articles with the rules in the flow chart above. It does not follow redirects when reading the list or checking article pages, and it sets a 10-second timeout. If reading the list fails, it gives up on that round. An article whose publish time is more than 1 day in the future is skipped, just like an old article older than 3 days.

The same cron also cleans up, in batches, deliveries, outbox, digests, and inactive subscriptions older than 30 days. articles is kept permanently, so old articles are not recorded again as new.

Sending notifications at 20:00 every day

The cron 0 12 * * * is 12:00 UTC, which is 20:00 Taiwan time. The actual trigger comes a few dozen seconds late, so the code first aligns the time to 12:00 UTC of that day as the baseline cutoff, and sends only within 15 minutes from cutoff. After the schedule fires, the Worker runs these steps in order:

  1. End if the master switch is off or the time is outside the sending window.
  2. Create that day’s digests row with INSERT OR IGNORE. If the write fails, that day was already handled, so end.
  3. Mark the articles that are not yet sent and were recorded before cutoff as that day’s notification, and build the notification content only from articles recorded in the last 24 hours. End if there are no articles.
  4. Process the first page of the subscriber list. Only subscriptions created before cutoff are selected, so a device that subscribes after 20:00 does not get that night’s notification.

This is how each page of the list is processed:

Flow for processing one page of the subscriber list: first claim the page with INSERT OR IGNORE into outbox, read the 99 active subscriptions with id greater than the cursor, write one outbox row per device, then check the window and master switch again, and finally send 99 single-device messages and 1 next-page message with a cursor through sendBatch. When the consumer receives the next-page message, it returns to the first step
One page of 99 devices plus the next-page message is exactly 100 messages, which is the maximum sendBatch can send at once. This is how the consumer handles a single-device message and what the send results are:

Flow for the consumer handling one single-device message: read outbox and that day’s notification, look up the subscription and control, check the master switch and window, write the delivery record first, then encrypt and send. The table on the right lists the send results: an endpoint outside the allowlist, an encryption exception, and 404 or 410 disable the subscription, and all other results are only logged

Sending Web Push from Workers

The content of a Web Push message must be encrypted with each device’s own keys (RFC 8291), and the request must carry a VAPID signature (RFC 8292). Both are handled by generateRequestDetails from the web-push package, so the Worker needs nodejs_compat turned on. TTL is set to the seconds left in the sending window, and topic uses the notification ID, so the push service replaces an undelivered older notification from the same day with the new one. The actual send uses the built-in fetch of Workers, does not follow redirects, has a 10-second timeout, and does not retry.

Worker configuration and deployment

The push Worker’s wrangler.jsonc needs these settings. The local ones go at the top level of the file, and the production ones go in env.production:

Setting Value Purpose
compatibility_flags ["nodejs_compat"] Lets the web-push package run on Workers
workers_dev, preview_urls false Keep only the custom domain
vars ENVIRONMENT, ALLOWED_ORIGINS, SITE_ORIGIN, VAPID_PUBLIC_KEY Environment, allowed origins, location of the article list, and the public key
d1_databases Bound as DB The database
queues The producer is bound as PUSH_QUEUE, and the consumer sets max_batch_size: 1 and max_retries: 0 Handle one message at a time and do not retry on failure
ratelimits 10 per 60 seconds The Rate Limiting binding for the subscription API
triggers.crons 0 12 * * *, 30 * * * * Production only

These are the steps to deploy to production:

  1. Create the D1 database and the Queue, and fill the D1 ID into env.production.
  2. Write the migration files from the data model and indexes above, and apply them to the production database.
  3. Set VAPID_PRIVATE_KEY, TURNSTILE_SECRET_KEY, and ADMIN_TOKEN with wrangler secret put.
  4. Create the WAF rules and Access settings mentioned above in the Cloudflare zone that owns the site.
  5. After local testing passes, deploy with wrangler deploy --env production.
  6. Once everything looks normal, set control.enabled to 1 to turn sending on, and set it back to 0 when you need to pause.

Testing real push locally

This site has no separate preview push Worker. Instead, I run the same code locally with wrangler dev. D1, Queues, and Rate Limiting are simulated locally, while the push itself really goes to Google’s FCM push service. Local secrets live in .dev.vars, which is not under version control.

Turnstile uses Cloudflare’s public test keys, which always pass verification. The hostname and action that the test secret returns are fixed test values, so the Worker checks only success when ENVIRONMENT=local and the secret equals the test value. The admin APIs that adjust the time offset, record articles by hand, and trigger sending by hand are also open only locally, and production always returns 404.

The test script uses Playwright to drive the Google Chrome already installed on the computer (channel: 'chrome'), because Playwright’s bundled Chromium has no push service. The script first confirms that the Worker is the local one, subscribes to the local site with a throwaway user profile, moves the Worker’s clock into the sending window, records one article by hand, and triggers sending. Finally it checks that the send results are all accepted and that Chrome shows a notification for this article. At the end it always unsubscribes, turns the master switch off, and restores the clock.

You can check the feature as four separate things: whether the push service accepted the request, whether the browser received it, whether the operating system displayed it, and whether clicking opens the right page. A 201 from the push service means only that it accepted the notification. When the operating system has notifications turned off or Do Not Disturb on, nothing appears on screen.

Summary

To add push notifications to a static site, split the work clearly: the site outputs the public settings, the Service Worker, and the article list, and the push Worker sends the notifications and decides which articles are new, meaning those published within 3 days that the database has no record of. At send time, three levels of primary keys (the notification content, the subscriber-list page, and the single device) make sure each device gets at most one delivery attempt per night. On the free Workers plan, the CPU time limit lets one message go to only one device and the Queue quota limits support to about 3,300 devices, and moving to the paid plan raises that limit.

The pros and cons of this design are summarized below:

  • Pro: The site stays purely static, readers never call the Worker when they browse articles, and no amount of traffic uses up the push quota.
  • Pro: When you publish an article, you deploy as usual, and the Worker finds new articles from the article list by itself, so you do not record new articles by hand or keep admin credentials on your machine.
  • Con: Each device gets at most one delivery attempt per night and delivery is not guaranteed. A failure, a timeout, or a used-up quota is never resent.
  • Con: The free plan runs out of Queue quota at about 3,300 devices, and from publishing to receiving a notification can take up to about one day.
  • Improvable: When devices exceed about 3,300, move to the Workers Paid plan so that one message can be sent to several devices.
  • Improvable: A 201 from the push service means only that it accepted the notification, and there is currently no way to see whether the device actually displayed it.

Where to go next

PreviousJev API integration notes: request structure, backend call flow, and reading the resultsAINextSwitch Minecraft Java 26.3 to Vulkan: how to enable it and FPS compared with OpenGLGames
Ted Liou

Ted Liou

Software engineer writing about Unity and AI development. Available for consulting.