Push Subscription Storage & Lifecycle

A PushSubscription is a credential, not a user profile row. It is issued by a push service, it can be revoked without telling you, and it carries key material that decrypts everything you send. This guide covers how to persist those records, how to index them, how to protect the secret inside them, and how to age them through a lifecycle that never sends to an address the browser has already thrown away.

Prerequisites

1. What a Subscription Record Actually Contains

registration.pushManager.subscribe() resolves to a PushSubscription whose toJSON() form has exactly three fields you care about: endpoint, expirationTime, and a keys object holding p256dh and auth. Everything else in your table is provenance and lifecycle metadata that you invent.

endpoint is an absolute HTTPS URL owned by the browser’s push service. The path segment is an opaque registration identifier — never parse it, never normalise it, never lowercase it. p256dh is the user agent’s ECDH public key on the P-256 curve, transmitted as a 65-byte uncompressed point in base64url (87 characters). auth is a 16-byte authentication secret in base64url (22 characters). Both feed the HKDF step of the aes128gcm content encoding described in RFC 8291, and both are useless without the other. expirationTime is almost always null; Chrome populates it only for subscriptions it intends to rotate, and no push service is obliged to honour it as a hard deadline.

Store the raw values verbatim. Base64url is not base64 — re-encoding p256dh through a padded base64 helper and back is the single most common cause of “encryption worked in staging” bugs, and the mechanics of that failure are covered in Push API Payload Encryption. Remember also that the ciphertext you eventually send is capped at 4 KB, so the record’s job is only to hold credentials, never message content.

Push subscription storage model One table row per endpoint, grouped into four column families: identity columns including a unique endpoint hash, crypto material holding the p256dh public key and an encrypted auth secret, provenance columns capturing user and user agent, and lifecycle columns tracking state and timestamps. push_subscription — exactly one row per endpoint Identity id uuid, primary key endpoint text, verbatim endpoint_hash bytea(32), unique push_service fcm / autopush / apple expiration_time timestamptz, usually null unique index on the hash Crypto material p256dh text, 65-byte point auth_sealed bytea, AES-256-GCM auth_key_version smallint, for rotation content_encoding aes128gcm auth is encrypted at rest Provenance user_id uuid, nullable user_agent text, truncated locale text, BCP 47 origin text, the subscribing site created_at timestamptz, immutable Lifecycle state enum, five values last_seen_at client re-registration last_success_at last 201 from the service consecutive_failures smallint counter revoked_at set on 410 Gone Four column families: what identifies the row, what encrypts the payload, where it came from, and how old it is.
The four column families of a subscription row. Only the middle group is secret; only the left group is identity.

2. The Endpoint URL Is the Natural Identity

There is exactly one stable identifier for a push subscription and the push service assigns it: the endpoint URL. It is globally unique across every browser, every profile and every device, because the registration ID inside it is minted by the service. Nothing you own has that property. A user ID identifies a person who may have six browsers. A session ID dies on logout. A device fingerprint is both privacy-hostile and wrong — two Chrome profiles on the same laptop produce two legitimately distinct endpoints, which is why deduplication must key on the endpoint and nothing else, as covered in deduplicating push subscriptions across devices.

Treat the endpoint as immutable. When the browser rotates it — after a service worker update, a profile restore, or a push service migration — that is a new subscription, not an edit of the old one. The client learns about the rotation through the pushsubscriptionchange event and should send you both the old and new endpoints so you can transfer the user association; the client-side half of that handshake is described in handling pushsubscriptionchange on the client.

Why you index a hash, not the URL

Endpoints are long and variable. FCM endpoints run around 180–230 bytes, Mozilla autopush around 130–160, Apple’s web push service around 160–200, and the RFC 8030 specification places no upper bound at all — a service could legitimately hand you a 2 KB URL tomorrow. That collides with real index limits:

  • PostgreSQL: a btree entry cannot exceed roughly 2704 bytes (one third of an 8 KB page). Exceeding it fails the INSERT, not the CREATE INDEX, so the problem appears in production rather than in migration.
  • MySQL/InnoDB: the index key prefix limit is 3072 bytes with DYNAMIC row format, which is only 768 characters at utf8mb4. Older COMPACT tables cap at 767 bytes total.
  • SQL Server: 1700 bytes for a non-clustered index key.

Even where the URL fits, a 200-byte variable-width key wastes buffer cache and slows every lookup. Store the endpoint verbatim in a text column for sending, and put the unique constraint on endpoint_hash, a fixed 32-byte SHA-256 digest. Lookups, joins, dedupe checks and log correlation all use the digest; only the dispatcher reads the URL.

Endpoint hashing for index keys A variable-length endpoint URL of up to two kilobytes passes through SHA-256 to produce a fixed 32-byte digest. A comparison of index key sizes shows the raw endpoint bar far exceeding btree limits while the hash bar stays at a constant 32 bytes. endpoint (text) https://fcm.googleapis.com/fcm/send/dQw4w9… 130–2048 bytes, opaque, service-assigned sha256() deterministic endpoint_hash bytea(32) · UNIQUE index on endpoint variable width — can exceed the btree key limit index on hash 32 B fixed width, cache-friendly, legal in every engine Keep the raw URL in the row for dispatch; index, join and log on the digest.
SHA-256 turns an unbounded URL into a constant-width unique key. The digest also doubles as the safe identifier for logs and analytics.

Use a plain SHA-256 for the index key, not an HMAC. The index has to be reproducible from the endpoint alone by any service in your estate, and a rotating HMAC key would invalidate every stored digest on rotation. Reserve the keyed HMAC for the logging identifier, where unlinkability across exports actually matters.

3. The SQL Schema

This is the full PostgreSQL definition. Nullable columns are deliberate: user_id is null for anonymous subscribers who have not yet signed in, and last_success_at is null until the first accepted send.

CREATE TYPE push_subscription_state AS ENUM
  ('pending', 'active', 'stale', 'expired', 'revoked');

CREATE TABLE push_subscription (
  id                   uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  endpoint             text        NOT NULL,
  endpoint_hash        bytea       NOT NULL,
  push_service         text        NOT NULL,
  p256dh               text        NOT NULL,
  auth_sealed          bytea       NOT NULL,
  auth_key_version     smallint    NOT NULL DEFAULT 1,
  content_encoding     text        NOT NULL DEFAULT 'aes128gcm',
  expiration_time      timestamptz,
  user_id              uuid        REFERENCES app_user (id) ON DELETE SET NULL,
  user_agent           text,
  locale               text,
  origin               text        NOT NULL,
  state                push_subscription_state NOT NULL DEFAULT 'pending',
  created_at           timestamptz NOT NULL DEFAULT now(),
  last_seen_at         timestamptz NOT NULL DEFAULT now(),
  last_success_at      timestamptz,
  last_failure_at      timestamptz,
  last_status_code     smallint,
  consecutive_failures smallint    NOT NULL DEFAULT 0,
  revoked_at           timestamptz,
  deleted_at           timestamptz,
  CONSTRAINT push_subscription_hash_uniq UNIQUE (endpoint_hash),
  CONSTRAINT push_subscription_hash_len  CHECK (octet_length(endpoint_hash) = 32),
  CONSTRAINT push_subscription_p256dh_len CHECK (char_length(p256dh) BETWEEN 80 AND 96)
);

-- Fan-out query: every live endpoint for one user.
CREATE INDEX push_subscription_user_live
  ON push_subscription (user_id)
  WHERE state IN ('pending', 'active') AND deleted_at IS NULL;

-- Ageing sweeps: find rows that have gone quiet.
CREATE INDEX push_subscription_sweep
  ON push_subscription (state, last_seen_at)
  WHERE deleted_at IS NULL;

-- Per-provider concurrency accounting and gateway routing.
CREATE INDEX push_subscription_service
  ON push_subscription (push_service, state);

Three notes on the shape. The unique constraint sits on endpoint_hash, never on (user_id, endpoint_hash) — a composite would let the same endpoint attach to two users, which is exactly the corruption you are trying to prevent. The partial indexes exclude soft-deleted rows so the working set stays small even when tombstones accumulate. And deleted_at is separate from state: revoked describes what the push service told you, while deleted_at describes what your retention policy did about it.

For MySQL, swap bytea for VARBINARY(32), timestamptz for TIMESTAMP(6), the enum for a TINYINT with a lookup table, and replace the partial indexes with full ones plus a generated is_live column. For DynamoDB, make the base64url endpoint hash the partition key and keep a global secondary index on user_id.

4. Encryption at Rest for the Auth Secret

p256dh is a public key and needs no protection beyond ordinary access control. auth is a shared secret. Together with the endpoint, it is one of the three inputs required to construct a valid encrypted push message for that user. A leaked database dump does not immediately let an attacker push to your subscribers — Chrome and Firefox bind each subscription to the applicationServerKey it was created with, so the push service rejects a request signed by a different VAPID key — but the row is still durable, user-linkable key material, and both GDPR Article 32 and SOC 2 treat it as such. Encrypt it.

Use envelope encryption rather than a database-level feature. Full-disk or tablespace encryption protects against a stolen drive and nothing else; a SQL injection or an over-broad read replica credential still returns plaintext. Column encryption at the application layer means the ciphertext is what leaves the database.

import crypto from 'node:crypto';

// A 32-byte data key unwrapped from KMS at boot and held only in memory.
// Never inline key material: the same rule that applies to
// process.env.VAPID_PRIVATE_KEY applies here.
const DATA_KEYS = new Map([
  [1, Buffer.from(process.env.SUBSCRIPTION_AUTH_KEY_V1, 'base64')],
  [2, Buffer.from(process.env.SUBSCRIPTION_AUTH_KEY_V2, 'base64')]
]);
const CURRENT_KEY_VERSION = Number(process.env.SUBSCRIPTION_AUTH_KEY_VERSION);

/** Seal the 16-byte auth secret as iv(12) || tag(16) || ciphertext(16). */
export function sealAuth(authBase64Url) {
  const key = DATA_KEYS.get(CURRENT_KEY_VERSION);
  const iv = crypto.randomBytes(12);
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
  const plaintext = Buffer.from(authBase64Url, 'base64url');
  if (plaintext.length !== 16) throw new Error('auth secret must be 16 bytes');
  const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
  return {
    sealed: Buffer.concat([iv, cipher.getAuthTag(), body]),
    keyVersion: CURRENT_KEY_VERSION
  };
}

/** Open a sealed auth secret using the version recorded on the row. */
export function openAuth(sealed, keyVersion) {
  const key = DATA_KEYS.get(keyVersion);
  if (!key) throw new Error(`no data key for version ${keyVersion}`);
  const iv = sealed.subarray(0, 12);
  const tag = sealed.subarray(12, 28);
  const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
  decipher.setAuthTag(tag);
  const plain = Buffer.concat([decipher.update(sealed.subarray(28)), decipher.final()]);
  return plain.toString('base64url');
}

The auth_key_version column is what makes rotation survivable. Roll a new data key, bump CURRENT_KEY_VERSION, and let new writes seal under version 2 while reads still open version 1. A background job re-seals old rows in chunks. Without the version column you are forced into a single-transaction re-encryption of the entire table, which is exactly the migration nobody ever schedules.

Do not encrypt endpoint. It has to be readable by the dispatcher on every send, indexing it would become impossible, and its secrecy value is low — it is a bearer URL that only accepts requests signed by your VAPID key.

5. The Lifecycle State Machine

Subscriptions decay. Browsers get uninstalled, users clear site data, push services garbage-collect registrations that have not been contacted in months. A record has five meaningful states and every transition is driven either by a push service response or by the passage of time.

State Meaning Eligible to send?
pending Row written from a client registration; no send attempted yet Yes, once
active Last dispatch returned 201/200 Yes
stale No accepted send inside the freshness window, or intermittent 404s Probe only
expired Failed the staleness threshold or accumulated repeated 404s No
revoked Push service returned 410 Gone Never

The transitions are unambiguous because the HTTP status codes are unambiguous. A 201 Created (or 200 OK from older services) sets state = 'active', stamps last_success_at, and zeroes consecutive_failures. A 404 Not Found is ambiguous — it can be a routing blip or a genuinely dead registration — so it increments the failure counter rather than deciding anything. A 410 Gone is terminal on first sight and moves the row straight to revoked; the fan-out mechanics for handling those at volume live in handling 410 Gone responses at scale. A 429 or any 5xx must not touch the lifecycle at all: those are transport problems that belong to the retry layer, and letting them increment a failure counter is how healthy subscribers get deleted during a push service incident.

Subscription lifecycle state machine Pending advances to active on the first accepted send. Active decays to stale after thirty days without a 2xx, and stale decays to expired after repeated 404s or ninety days. Any 2xx returns a stale row to active. A 410 Gone moves active straight to revoked, and expired rows are purged after the audit window. pending row written, no send active last send accepted stale no recent 2xx expired not deliverable first 201 / 200 accepted 30 d with no 2xx 3 × 404 or 90 d stale revoked 410 Gone received 410 Gone — terminal purged row hard-deleted after the audit window any 2xx resets the counter 429 and 5xx never move the state machine — they belong to the retry layer.
Five states, four status-driven transitions and one time-driven decay. Only 410 is terminal on first sight.

last_seen_at deserves separate treatment from last_success_at. The former is written by the client — every page load where the service worker confirms an existing subscription should refresh it — and it is your only signal that a user is still around when you have not sent them anything for weeks. Throttle that write: an UPDATE on every page view of a busy site will produce more write amplification than the entire push pipeline. A one-hour throttle in Redis in front of the update is usually enough.

6. Implementing the Upsert

Registration is an upsert, not an insert. The same browser will re-post the same subscription on every service worker activation, and the write must be idempotent.

  1. Validate before touching the database. Reject endpoints whose origin is not on your allow-list of known push services, reject p256dh values that do not decode to 65 bytes, and reject auth values that do not decode to 16.
  2. Derive the hash. sha256(endpoint) as raw bytes, computed identically in every service that writes to this table.
  3. Seal the auth secret with the current data key version.
  4. Upsert on the hash. ON CONFLICT (endpoint_hash) DO UPDATE refreshes the keys, the user association and last_seen_at, and resurrects a soft-deleted row rather than colliding with it.
  5. Never downgrade a state on upsert. A re-registration should move revoked back to pending, but must not move active back to pending.
  6. Return the row id so the client can correlate later pushsubscriptionchange reports.
import crypto from 'node:crypto';
import { Pool } from 'pg';
import { sealAuth } from './auth-crypto.js';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

const ALLOWED_HOSTS = new Set([
  'fcm.googleapis.com',
  'updates.push.services.mozilla.com',
  'web.push.apple.com',
  'wns2-by3p.notify.windows.com'
]);

const UPSERT = `
INSERT INTO push_subscription (
  endpoint, endpoint_hash, push_service, p256dh, auth_sealed, auth_key_version,
  expiration_time, user_id, user_agent, locale, origin, state
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'pending')
ON CONFLICT (endpoint_hash) DO UPDATE SET
  p256dh           = EXCLUDED.p256dh,
  auth_sealed      = EXCLUDED.auth_sealed,
  auth_key_version = EXCLUDED.auth_key_version,
  expiration_time  = EXCLUDED.expiration_time,
  user_id          = COALESCE(EXCLUDED.user_id, push_subscription.user_id),
  user_agent       = EXCLUDED.user_agent,
  locale           = EXCLUDED.locale,
  last_seen_at     = now(),
  deleted_at       = NULL,
  revoked_at       = NULL,
  consecutive_failures = 0,
  state = CASE
            WHEN push_subscription.state IN ('expired', 'revoked') THEN 'pending'
            ELSE push_subscription.state
          END
RETURNING id, state, (xmax = 0) AS inserted;
`;

export async function saveSubscription(sub, ctx) {
  const url = new URL(sub.endpoint);
  if (url.protocol !== 'https:' || !ALLOWED_HOSTS.has(url.hostname)) {
    throw new Error('unrecognised push service endpoint');
  }
  if (Buffer.from(sub.keys.p256dh, 'base64url').length !== 65) {
    throw new Error('p256dh is not a 65-byte P-256 point');
  }

  const endpointHash = crypto.createHash('sha256').update(sub.endpoint, 'utf8').digest();
  const { sealed, keyVersion } = sealAuth(sub.keys.auth);

  const { rows } = await pool.query(UPSERT, [
    sub.endpoint,
    endpointHash,
    url.hostname.includes('mozilla') ? 'autopush'
      : url.hostname.includes('apple') ? 'apple'
      : url.hostname.includes('windows') ? 'wns' : 'fcm',
    sub.keys.p256dh,
    sealed,
    keyVersion,
    sub.expirationTime ? new Date(sub.expirationTime) : null,
    ctx.userId ?? null,
    (ctx.userAgent ?? '').slice(0, 400),
    ctx.locale ?? null,
    ctx.origin
  ]);

  return rows[0];
}

(xmax = 0) AS inserted is a PostgreSQL idiom that tells you whether the statement inserted or updated. Emit it as a metric: a sudden collapse in the insert ratio means your client is re-posting subscriptions it should be caching, and a sudden spike means endpoints are churning — usually the sign of a service worker deployment problem.

7. Configuration Reference

Defaults below assume a consumer product sending a few notifications a week. Halve the windows for daily senders; double them for monthly digests.

Parameter Type Default Notes
staleAfterDays integer 30 Days without an accepted send before active becomes stale
expireAfterDays integer 90 Days in stale before the row is marked expired
notFoundThreshold integer 3 Consecutive 404s that expire a row; 410 bypasses this entirely
lastSeenThrottleSec integer 3600 Minimum interval between last_seen_at writes for one endpoint
endpointHashAlgo string sha256 Must match across every writer; changing it is a full-table backfill
authKeyVersion integer 1 Current data key generation for sealing auth
reSealBatchRows integer 2000 Rows re-encrypted per chunk during key rotation
softDeleteRetentionDays integer 90 How long a tombstone survives before hard deletion
erasureSlaDays integer 30 Maximum time to satisfy an Article 17 request, overriding all other windows
backfillChunkRows integer 5000 Rows per keyset-paginated migration chunk
maxEndpointBytes integer 2048 Reject longer endpoints at the API boundary rather than at the storage layer

8. Retention Windows and GDPR Erasure

An endpoint plus a user_id is personal data under GDPR, and even an anonymous subscription is a persistent identifier under ePrivacy. Three obligations apply and they pull in different directions.

Storage limitation (Article 5(1)(e)) says you may not keep a subscription longer than you need it. A dead endpoint has no purpose, so the expiry sweep is a compliance control, not just housekeeping. Accountability (Article 5(2)) says you must be able to demonstrate that consent existed and when it ended, which argues for keeping a tombstone after deletion. Erasure (Article 17) says a valid request collapses every other window to a hard deadline.

Reconcile them with a tiered schedule. Live rows keep everything. On expiry or revocation, soft-delete: set deleted_at, null out user_agent and locale, and keep only endpoint_hash, user_id, created_at and revoked_at. That tombstone answers “did this person consent, and when did it end?” without retaining the credential. After the audit window, hard-delete the row and fold it into an aggregate counter. The unsubscribe-side logging obligations are covered in more depth in GDPR-compliant push unsubscribe logging, and the mechanics of running the deletion itself are in pruning expired push subscriptions from your database.

Subscription retention schedule A timeline in days shows an active window to day thirty, a stale probe-only window to day ninety, a soft-deleted window to day one hundred and eighty, and an audit tombstone beyond it, with hard deletion of personal data marked at day one hundred and eighty. day 0 30 90 180 365 active — full record stale — probe only expired — soft-deleted aggregate counter only hard delete of personal data An Article 17 erasure request collapses the whole schedule: purge within 30 days whatever the state.
A tiered retention schedule satisfies storage limitation and accountability at the same time. Erasure requests override all of it.

Erasure has one operational subtlety. If you delete the row and the user’s browser still holds a live subscription, the next re-registration silently recreates it. A genuine erasure therefore has to be paired with a server-side signal that tells the client to call subscription.unsubscribe(), otherwise you have deleted the record and not the relationship.

9. Schema Migrations and Backfill

Subscription tables get large — tens of millions of rows for a mid-size consumer product — and they are on the hot path of every campaign. Migrations must be online.

Add columns as nullable with no default. In PostgreSQL 11+ a constant default is also safe, but a volatile one (gen_random_uuid(), now()) forces a full rewrite. Add the constraint separately as NOT VALID, then VALIDATE CONSTRAINT in a second transaction that takes only a SHARE UPDATE EXCLUSIVE lock. Build indexes CONCURRENTLY and expect to have to clean up an INVALID index if the build is interrupted.

Backfill with keyset pagination, never OFFSET:

-- Chunked backfill of endpoint_hash for rows written before the column existed.
WITH batch AS (
  SELECT id, endpoint
  FROM push_subscription
  WHERE endpoint_hash IS NULL AND id > $1
  ORDER BY id
  LIMIT 5000
)
UPDATE push_subscription s
SET endpoint_hash = digest(b.endpoint, 'sha256')
FROM batch b
WHERE s.id = b.id
RETURNING s.id;

Drive it from a job that carries the last returned id forward as $1, sleeps 50–100 ms between chunks, and aborts if replication lag crosses a threshold. Only after the backfill reports zero remaining rows do you add the NOT NULL and the unique index — adding the unique index first will fail the moment two legacy rows hash to the same endpoint, which happens more often than you expect because old schemas frequently stored the same endpoint twice under different user IDs.

For a hash-algorithm change, add endpoint_hash_v2 alongside, dual-write both from the application, backfill, swap the index, then drop the old column in a later release. Never mutate a unique key in place.

Verification

Confirm the storage layer behaves before you trust it with a campaign.

# 1. Re-post the same subscription twice — the second must update, not insert.
curl -sX POST https://localhost:3000/api/push/subscribe \
  -H 'content-type: application/json' \
  --data-binary @fixtures/subscription.json | tee /tmp/first.json
curl -sX POST https://localhost:3000/api/push/subscribe \
  -H 'content-type: application/json' \
  --data-binary @fixtures/subscription.json | tee /tmp/second.json
# Expect identical "id", "inserted": true then false.

# 2. Confirm exactly one row and a 32-byte digest.
psql "$DATABASE_URL" -c \
  "SELECT count(*), octet_length(endpoint_hash) FROM push_subscription GROUP BY 2;"

# 3. Confirm the auth secret is not readable in the dump.
pg_dump --data-only -t push_subscription "$DATABASE_URL" | grep -c 'BQ_' || echo 'auth sealed'

In DevTools, open Application → Service Workers, click Push to fire a test message, and confirm the row’s last_success_at advanced. Then unsubscribe from the console with navigator.serviceWorker.ready.then(r => r.pushManager.getSubscription()).then(s => s.unsubscribe()) and send again — the next dispatch should return 410 and flip the row to revoked.

Error & Edge-Case Matrix

Condition Cause Fix
INSERT fails with “index row size exceeds maximum” Unique index placed on the raw endpoint column Move the constraint to endpoint_hash; keep endpoint unindexed
Duplicate rows for one endpoint Unique constraint is composite with user_id, or hashes computed with different encodings Enforce UNIQUE (endpoint_hash) alone and normalise on raw UTF-8 bytes
Decryption fails after a key rotation auth_key_version not written, so every row is opened with the newest key Backfill the version column, then re-seal in chunks
p256dh decode errors during encryption Value round-tripped through padded base64 Store the base64url string exactly as toJSON() returned it
Healthy subscribers deleted overnight 5xx or 429 incrementing consecutive_failures Restrict the counter to 404; route transport errors to the retry layer
Row count grows without bound Tombstones never hard-deleted Schedule the purge job and monitor tombstone ratio
Write amplification on last_seen_at Unthrottled update on every page view Put a one-hour Redis guard in front of the update
Re-registration collides with a soft-deleted row Upsert does not clear deleted_at Set deleted_at = NULL in the DO UPDATE branch

Cross-Browser Notes

Chrome, Edge and other Chromium browsers issue FCM endpoints on fcm.googleapis.com and are the only major implementation that occasionally populates expirationTime. Firefox issues Mozilla autopush endpoints on updates.push.services.mozilla.com, always with expirationTime: null, and rotates endpoints noticeably less often. Safari issues web.push.apple.com endpoints; on iOS a subscription only exists at all once the site is installed to the Home Screen, which makes iOS rows unusually short-lived when users delete the icon — see Safari & iOS web push integration for the constraints that implies.

Store the derived push_service value rather than inferring it at send time. Provider-specific concurrency limits, retry budgets and connection pools all key off it, and the string is stable in a way that hostname parsing across four vendors is not.

Back to Backend Delivery Architecture & Queue Management

FAQ

Should the primary key be the endpoint or a surrogate UUID?

Use a surrogate UUID as the primary key and put a unique constraint on the 32-byte endpoint hash. Foreign keys from delivery ledgers and preference tables then reference a compact, stable value, while the hash still enforces one row per endpoint. Making the endpoint itself the primary key propagates a 200-byte variable-width key into every referencing table.

Do I need to encrypt the p256dh key as well as auth?

No. p256dh is the user agent’s ECDH public key and carries no secrecy requirement. The auth value is a 16-byte shared secret used in the HKDF step of the aes128gcm encoding, so it is the field that warrants column-level encryption with a versioned data key.

What should happen when a 404 comes back from the push service?

Increment consecutive_failures and stamp last_failure_at, but do not change the state. A 404 can be a transient routing failure inside the push service. Only after the configured threshold — three consecutive 404s is a common setting — should the row move to expired. A 410 Gone is different: it is authoritative on first sight.

How long can a subscription stay valid without any activity?

Indefinitely in principle — there is no protocol-level expiry, and expirationTime is null for almost every subscription. In practice browsers drop registrations when site data is cleared, when the service worker is unregistered, or after long periods of user inactivity. Treat 30 days without an accepted send as stale and 90 days as expired rather than waiting for a push service to tell you.

Can I store subscriptions in Redis instead of a relational database?

Redis works well as a read-through cache keyed by endpoint hash, but it is a poor system of record. You need durable audit timestamps for consent, transactional upserts to avoid duplicate rows, and range scans over last_seen_at for the ageing sweeps. Keep the source of truth in a relational store and cache the hot fan-out lists.