Index: .env.example
===================================================================
--- .env.example	(revision 8240e808ac4b40aeea736b2c331462cff9c1a3e5)
+++ .env.example	(revision 96c714f68f23cf079e786ef6c33248d540533ab2)
@@ -15,4 +15,9 @@
 # so a database dump alone stays useless). Or set your own: openssl rand -base64 32
 PAID_SECRET=
+# Web-push (notificaties) VAPID keys. Leave EMPTY to auto-generate on first use
+# (saved to storage/.vapid). Do NOT rotate: new keys break every subscription.
+VAPID_PUBLIC_KEY=
+VAPID_PRIVATE_KEY=
+VAPID_SUBJECT=
 DATABASE_PATH=./storage/database.sqlite
 MEDIA_PATH=./storage/media
Index: README.md
===================================================================
--- README.md	(revision 8240e808ac4b40aeea736b2c331462cff9c1a3e5)
+++ README.md	(revision 96c714f68f23cf079e786ef6c33248d540533ab2)
@@ -22,4 +22,8 @@
   sites, Mastodon, PeerTube — any fediverse server) and their public posts appear in your
   Circle. Decentralized, no central platform — built on ActivityPub.
+- **Push notifications** — a browser/PWA notification for new followers, replies,
+  mentions, likes, boosts and private messages, even with the site closed.
+  Self-hosted Web Push (VAPID): payloads are encrypted end-to-end to your
+  browser, and private messages never carry their text in the push.
 - **Themes & languages** — multiple palettes (light + dark), interface in EN/NL/DE.
 - **Installable (PWA)**, **privacy-first** (self-hosted fonts, no tracking).
@@ -168,4 +172,5 @@
 | `PUBLIC_BASE_URL` | ✅ | Canonical URL (e.g. `https://yourdomain.com`) |
 | `PAID_SECRET` | auto | Encrypts the stored Patreon secrets for **paid posts**. Leave empty to auto-generate on first use (`storage/.paid-secret`), or set your own ≥16-char string. |
+| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` | auto | Identify this server to browser push services (**push notifications**). Leave empty to auto-generate on first use (`storage/.vapid`). |
 | `SMTP_HOST` / `_PORT` / `_USER` / `_PASS` / `_FROM` | — | Email for password reset + newsletter |
 | `KLONKT_DEFAULT_LANG` | — | Default language for visitors (`en`/`nl`/`de`) |
@@ -182,4 +187,6 @@
 - `PAID_SECRET` → `storage/.paid-secret` (encrypts the stored Patreon secrets for
   paid posts)
+- `VAPID_*` → `storage/.vapid` (identifies this server to browser push services;
+  regenerating it would silently break every existing push subscription)
 
 The paid-posts key lives **outside** the database on purpose: encrypting the
@@ -189,5 +196,7 @@
 **Back up the whole `storage/` directory** (database, media *and* these key
 files). Restoring the database without `storage/.paid-secret` leaves the stored
-Patreon secrets unreadable — you'd have to reconnect Patreon.
+Patreon secrets unreadable — you'd have to reconnect Patreon. Restoring without
+`storage/.vapid` breaks push subscriptions — every device would have to re-enable
+notifications.
 
 ## Stack
Index: src/services/PushService.js
===================================================================
--- src/services/PushService.js	(revision 8240e808ac4b40aeea736b2c331462cff9c1a3e5)
+++ src/services/PushService.js	(revision 96c714f68f23cf079e786ef6c33248d540533ab2)
@@ -118,8 +118,25 @@
 }
 
+// Burst throttle: a wave of likes or a mass-follow must not become a wave of
+// pushes. Per (user, type) at most one push per window; extras drop silently
+// (the events themselves are still in Berichten — only the ping is deduped).
+// In-memory is fine: one process, and a restart just means one extra ping.
+const THROTTLE_SECONDS = { follow: 60, reply: 30, dm: 30, like: 300, boost: 300, test: 0 };
+const _lastPush = new Map();
+export function throttled(userId, type, nowSeconds = Math.floor(Date.now() / 1000)) {
+  const windowS = THROTTLE_SECONDS[type] ?? 60;
+  if (!windowS) return false;
+  const key = `${userId}:${type}`;
+  const prev = _lastPush.get(key) || 0;
+  if (nowSeconds - prev < windowS) return true;
+  _lastPush.set(key, nowSeconds);
+  return false;
+}
+
 // Notify one user on all their devices, honouring per-type preferences.
 // type ∈ {follow, reply, like, boost, dm, test}. Fire-and-forget at call sites.
 export async function notifyUser(userId, { type, title, body, url }) {
   if (!(await pushReady())) return 0;
+  if (throttled(userId, type)) return 0;
   const rows = db.prepare('SELECT * FROM push_subscriptions WHERE user_id = ?').all(userId);
   let sent = 0;
@@ -143,5 +160,5 @@
 
 export default {
-  publicKey, pushReady, DEFAULT_ALERTS,
+  publicKey, pushReady, DEFAULT_ALERTS, throttled,
   saveSubscription, deleteSubscription, listSubscriptions, updateAlerts,
   notifyUser, notifySite,
Index: test/push.test.js
===================================================================
--- test/push.test.js	(revision 8240e808ac4b40aeea736b2c331462cff9c1a3e5)
+++ test/push.test.js	(revision 96c714f68f23cf079e786ef6c33248d540533ab2)
@@ -65,4 +65,14 @@
 });
 
+test('burst throttle: one ping per window per (user,type); test type never throttles', () => {
+  assert.equal(Push.throttled('tu1', 'like', 1000), false);   // first passes
+  assert.equal(Push.throttled('tu1', 'like', 1100), true);    // within 300s window
+  assert.equal(Push.throttled('tu1', 'like', 1301), false);   // window elapsed
+  assert.equal(Push.throttled('tu1', 'boost', 1000), false);  // other type independent
+  assert.equal(Push.throttled('tu2', 'like', 1000), false);   // other user independent
+  assert.equal(Push.throttled('tu1', 'test', 1000), false);   // test bypasses
+  assert.equal(Push.throttled('tu1', 'test', 1001), false);
+});
+
 test('incomplete subscription payloads are refused', () => {
   assert.equal(Push.saveSubscription({ endpoint: '', userId: 'u1', p256dh: 'x', auth: 'y' }), false);
