Pruning Expired Push Subscriptions From Your Database

Deleting a dead subscription is easy; deleting it without destroying your consent audit trail, your unsubscribe analytics or your database’s write throughput is the actual problem.

Quick Answer

Never hard-delete on the first failure. Classify the push service response, mark the row soft-deleted with a timestamp, and let a separate scheduled job hard-delete tombstones once they clear a retention window. Do the deletion in keyset-paginated batches of a few thousand rows with a pause between them, so no single statement holds a lock long enough to stall dispatch or blow up replica lag.

Signal Immediate action Row lifecycle
410 Gone Stop sending, set deleted_at now Soft-deleted on first sight
404 Not Found Increment the failure counter Soft-deleted at the third consecutive strike
403 / 401 Stop sending, alert Quarantine — a VAPID problem, not a dead endpoint
429 / 5xx Requeue with backoff No lifecycle change at all
No send for 90 days Age out Soft-deleted by the staleness sweep
Prune decision tree by response code A dispatch response branches three ways. A 410 Gone soft-deletes the row immediately. A 404 Not Found increments a strike counter and only expires the row at the third strike. A 429 or 5xx is requeued and leaves the lifecycle untouched. Dispatch response 410 Gone authoritative, never retry 404 Not Found ambiguous, count it 429 / 5xx transport, not identity soft-delete now deleted_at = now() expire at 3 strikes any 2xx clears the count requeue row untouched Only the left branch is a deletion decision. Confusing the right branch with it deletes healthy subscribers during an outage.
Three branches, one of which deletes. Everything else either counts or retries.

404 and 410 Carry Different Guarantees

RFC 8030 gives 410 Gone a specific meaning for push: the subscription no longer exists and will never exist again at that URL. The push service is asserting a fact about its own state, and no retry can change it. The correct handling at volume — batching the revocations, keeping them out of the retry path — is covered in handling 410 Gone responses at scale.

404 Not Found asserts nothing so strong. In practice it appears in three situations: the registration really is gone but the service returns 404 instead of 410 (Mozilla autopush has historically done this for some record states), a request landed on a node that had not yet replicated the registration, or a malformed path made the URL unroutable. The first is permanent, the second is transient, the third is your bug. Because you cannot distinguish them from a single response, 404 must be a vote rather than a verdict. Three consecutive 404s across separate dispatch attempts, ideally separated by hours, is a reasonable threshold. Any 2xx in between resets the counter to zero.

The failure mode worth guarding against is treating 429 or 503 as evidence of a dead endpoint. During a push service incident, every request fails at once. A pruning rule that keys on “the last send failed” will empty a healthy subscriber table in a single evening. Rate limiting and server errors belong entirely to the retry and backoff layer and must never touch the lifecycle columns described in the subscription storage and lifecycle guide.

Soft Delete First, Hard Delete Later

A hard delete on the dispatch path is tempting because it is one statement. It is also irreversible, it happens inside a latency-sensitive request, and it destroys the only record that the subscription ever existed.

Soft deletion means setting deleted_at and removing the row from every send query, while leaving the tombstone in place. Partial indexes with WHERE deleted_at IS NULL keep the working set the same size it would have been after a hard delete, so there is no query-performance argument for deleting immediately.

Soft-delete to hard-delete retention windows A live sendable row becomes a tombstone at the moment of pruning. The tombstone survives ninety days serving analytics and endpoint resurrection, after which the purge job hard-deletes it and only aggregate counters remain. 410 received — prune purge job — hard delete live · sendable tombstone · analytics + resurrection aggregate counters only t0 prune +90 d +365 d Inside the tombstone window a returning endpoint is an update. Outside it, the same endpoint looks like a brand-new subscriber.
The tombstone window is what makes churn measurable and re-subscription distinguishable from first-time opt-in.

What an Immediate Hard Delete Actually Costs

The consent audit trail disappears. If a regulator or a customer asks when a person opted in and when the subscription ended, a deleted row answers neither question. The tombstone — endpoint hash, user ID, created_at, revoked_at, and nothing else — answers both while holding no credential material. The record-keeping obligations are set out in more detail in GDPR-compliant push unsubscribe logging.

Unsubscribe analytics become uncomputable. Churn rate needs a denominator. If revoked rows vanish the instant they fail, your subscriber count only ever reflects survivors, and week-over-week churn silently reads as zero. You also lose the ability to attribute churn to a campaign: the question “did Tuesday’s send cause the revocations?” requires knowing that a row existed on Monday and was revoked on Tuesday.

Re-subscription looks like acquisition. A user who clears site data and opts back in the next day produces a fresh endpoint. With tombstones you can see that the same user_id had a subscription two days ago and label it a reconnection. Without them, every returning user inflates your new-subscriber metric.

Cascades fire too early. Delivery ledgers, preference rows and campaign membership tables usually reference the subscription. A hard delete either cascades and destroys delivery history, or it fails on a foreign key inside the dispatch worker — neither is a good outcome at three in the morning.

Batched Pruning That Does Not Lock the Table

The naive purge is a single DELETE ... WHERE deleted_at < now() - interval '90 days'. On a table with two million tombstones this acquires row locks on every matched row, holds them for the whole statement, generates one enormous WAL record, and blocks autovacuum from reclaiming anything until it commits. Replica lag spikes, dispatch queries queue behind it, and the whole thing rolls back if it times out — leaving you exactly where you started, only slower.

Batch it with keyset pagination on the primary key. Each batch is its own transaction, so locks are released every few milliseconds.

-- One batch. $1 = last id from the previous batch, $2 = retention cutoff.
WITH doomed AS (
  SELECT id
  FROM push_subscription
  WHERE deleted_at IS NOT NULL
    AND deleted_at < $2
    AND id > $1
  ORDER BY id
  LIMIT 5000
  FOR UPDATE SKIP LOCKED
)
DELETE FROM push_subscription s
USING doomed d
WHERE s.id = d.id
RETURNING s.id;

FOR UPDATE SKIP LOCKED means a dispatch worker holding a row lock never blocks the purge, and the purge never blocks the dispatcher. The batch simply skips that row and picks it up on the next pass.

The driver is a loop with three safety valves: a keyset cursor so no batch re-scans the previous one, a pause so autovacuum and replication get airtime, and a lag check that stops the job rather than degrading the read replicas.

import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const BATCH = Number(process.env.PRUNE_BATCH_ROWS ?? 5000);
const PAUSE_MS = Number(process.env.PRUNE_PAUSE_MS ?? 100);
const MAX_LAG_BYTES = 64 * 1024 * 1024;

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function replicaLagBytes(client) {
  const { rows } = await client.query(
    `SELECT coalesce(max(pg_wal_lsn_diff(sent_lsn, replay_lsn)), 0)::bigint AS lag
     FROM pg_stat_replication`
  );
  return Number(rows[0].lag);
}

export async function prunePass({ retentionDays = 90, budgetMs = 240000 } = {}) {
  const deadline = Date.now() + budgetMs;
  const cutoff = new Date(Date.now() - retentionDays * 86400000);
  let cursor = '00000000-0000-0000-0000-000000000000';
  let removed = 0;

  const client = await pool.connect();
  try {
    while (Date.now() < deadline) {
      if (await replicaLagBytes(client) > MAX_LAG_BYTES) {
        await sleep(5000);
        continue;
      }
      const { rows } = await client.query(PRUNE_BATCH_SQL, [cursor, cutoff]);
      if (rows.length === 0) break;
      cursor = rows[rows.length - 1].id;
      removed += rows.length;
      await sleep(PAUSE_MS);
    }
  } finally {
    client.release();
  }
  return { removed, resumeCursor: cursor, exhausted: removed < BATCH };
}

Note the returned resumeCursor. A purge that runs on a four-minute budget inside a five-minute cron slot will not finish a large backlog in one pass, and restarting from the beginning every time means the tail never gets deleted. Persist the cursor.

Bulk delete versus batched keyset pruning A single delete over two point one million rows holds locks for ninety four seconds, while batched deletes of five thousand rows hold locks for about forty milliseconds each. Below, the batch loop cycles through selecting by keyset, deleting the batch, pausing, and checking replica lag. Lock hold time per statement, 2.1 M tombstones one bulk DELETE ~94 s held · replicas lag · autovacuum blocked batched 5 000 rows ~40 ms per batch × 420 batches, lock released between each select by keyset id > cursor, LIMIT n delete batch SKIP LOCKED pause 100 ms let autovacuum run check replica lag stop above 64 MB carry the cursor forward until the budget expires Each batch is its own transaction, so dispatch never queues behind the purge.
Batching converts one long lock into hundreds of short ones and keeps replication inside its budget.

MySQL has no RETURNING, so run a SELECT ... FOR UPDATE SKIP LOCKED to collect IDs, then DELETE ... WHERE id IN (...) in the same transaction. Keep batches smaller — 1000 rows — because InnoDB gap locks are wider than PostgreSQL’s row locks.

Idempotency When the Endpoint Comes Back

Endpoints reappear more often than intuition suggests. A user disables notifications, changes their mind an hour later, and the browser hands out a subscription whose endpoint string is byte-identical to the one you just pruned. Push services reuse registration IDs within a session more readily than they mint new ones.

Three rules make this safe.

  1. Prune by endpoint hash, never by row ID captured earlier. A worker that queued “delete row 4471” at 09:00 and executes it at 09:05 may delete a row that was resurrected at 09:03. Delete conditionally: WHERE endpoint_hash = $1 AND revoked_at IS NOT NULL AND revoked_at < $2.
  2. Make the resurrection path an upsert, not an insert. The ON CONFLICT (endpoint_hash) DO UPDATE branch must clear deleted_at, clear revoked_at, and zero the failure counter. If your registration handler inserts blindly, a returning endpoint raises a unique violation against a tombstone and the user silently fails to re-subscribe.
  3. Keep the tombstone’s created_at. Resurrecting a row preserves the original subscription date, which is what makes the reconnection visible in analytics. Overwriting it turns a returning user into a new one.

Rule 2 is where most implementations break, and the symptom is distinctive: registration returns 500 only for users who previously unsubscribed, and only until the purge job finally removes the tombstone.

Metrics to Watch

Metric Definition Healthy range What a breach means
Prune rate Soft-deletes ÷ active subscriptions per week 0.5–2% A spike above 5% usually means 404 or 5xx is being misclassified as terminal
Resurrection rate Tombstones revived ÷ tombstones created 3–10% Near zero suggests the upsert path is failing on the unique constraint
Tombstone ratio Soft-deleted rows ÷ total rows Under 25% Rising steadily means the purge job is not keeping up with its backlog
Purge backlog age Age of the oldest row past its retention cutoff Under 7 days Growing age is a retention-compliance risk, not just a storage one
Batch p99 duration Slowest delete batch in a pass Under 250 ms Long batches indicate the index is not being used or the batch size is too large
Revocations per campaign Soft-deletes attributed to one send Flat A single campaign spiking is a content or frequency problem, visible through delivery analytics instrumentation

Alert on the prune rate first. It is the metric that catches a classification bug before it has deleted a meaningful fraction of your list, and it is cheap to compute from the same counters the dispatcher already emits.

Gotchas & Edge Cases

  • A purge job that also handles staleness will double-count. Keep “mark expired” and “hard delete tombstones” as separate jobs with separate schedules and separate metrics; merging them makes the prune rate impossible to interpret.
  • Foreign keys from the delivery ledger will block the delete. Use ON DELETE SET NULL on the ledger side and retain the endpoint hash there, so delivery history survives the subscription.
  • autovacuum will not reclaim space during a long-running read. An analytics query holding a snapshot for twenty minutes pins every dead tuple the purge created. Check pg_stat_activity for old transactions before blaming the job.
  • Deleting a row does not unsubscribe the browser. The client still holds a live PushSubscription and will re-register it on the next visit. If the intent is genuine removal, the client must call unsubscribe() as well.
  • A vendor outage can look like mass revocation. Add a circuit breaker: if terminal classifications exceed a threshold per minute, stop pruning and page someone. It is always cheaper to prune late than to prune wrongly.

Back to Push Subscription Storage & Lifecycle

FAQ

How soon after a 410 Gone should the row be deleted?

Soft-delete immediately — within the same dispatch handler — because the endpoint will never accept another message. Hard-delete only after the retention window, typically 90 days, so the tombstone can still answer consent and churn questions. The two actions belong to different jobs on different schedules.

What batch size should a pruning job use?

Start at 5000 rows per batch on PostgreSQL and 1000 on MySQL, with a 100 ms pause between batches, then tune until p99 batch duration sits under 250 ms and replica lag stays flat. Larger batches are not faster overall; they just concentrate the same work into locks long enough to stall dispatch.

Why do users report failing to re-subscribe after unsubscribing?

The registration handler is almost certainly inserting rather than upserting, so the returning endpoint collides with its own tombstone on the unique index. Fix the conflict branch to clear deleted_at and revoked_at and reset the failure counter, keeping the original created_at so the reconnection stays visible in analytics.