- Aug 5, 2026
- 4 min read
Retention as a KV TTL: Let the Platform Enforce Your Privacy Policy
My privacy policies say enquiries are kept “up to 24 months after our last exchange”. Writing that sentence is easy. Making it true usually means a cleanup job that someone has to write, schedule and keep running.
Cloudflare KV can do it instead: every value can carry an expiration, and KV deletes it on its own.
Expiration at write time
The forms Worker (see one forms Worker for every site) sets the TTL when it stores the lead:
const ttl = RETENTION_SECONDS[lead.kind];
await env.LEADS.put(key, JSON.stringify(lead), {
...(ttl ? { expirationTtl: ttl } : {}),
metadata: { site: lead.site, kind: lead.kind, email: lead.email /* ... */ },
});
No cron, no “delete where older than” query, nothing to forget.
One TTL for everything was wrong
My first version gave every lead 730 days. Adding a quarterly newsletter on fossengineer.com made the gap obvious: a subscription is valid until the person withdraws consent. A 24-month TTL would have silently dropped every subscriber after two years, without anyone unsubscribing.
So retention became a property of the form kind:
export const RETENTION_SECONDS: Record<Kind, number | undefined> = {
enquiry: 730 * 24 * 60 * 60,
ebook: 730 * 24 * 60 * 60,
newsletter: undefined, // kept until the person unsubscribes
};
| Kind | KV expiration | Privacy-policy wording |
|---|---|---|
enquiry | 730 days | Up to 24 months after our last exchange |
ebook | 730 days | Up to 24 months |
newsletter | none | Until you unsubscribe |
The rule behind it: retention follows the legal basis. An enquiry is kept to answer and follow up on a request, so it has an end date. A subscription rests on consent, so it ends when consent ends. I recorded the decision in an ADR and updated the privacy pages in the same change; a test asserts that newsletter leads are stored without expiry.
A subscription without expiry means unsubscribes must actually delete or suppress — which is what the double opt-in and suppression work later in this series is for.
Gotcha: re-putting a value resets its TTL
KV has no “update this field” operation. Changing a lead means writing the
whole value again, and a new put sets a new expiration — or none, if you
don’t pass one.
That happens in my Worker when someone confirms a subscription: the lead is
rewritten with status: "confirmed". The code re-applies the kind’s retention
on every write, so a value never loses its TTL by accident. For newsletters
nothing changes (no expiry). For a kind with a TTL, a rewrite extends its life
from the time of the write — something to keep in mind if you ever update
enquiries.
The safe pattern is one helper that every write goes through:
const putLead = (env: Env, key: string, lead: Lead) => {
const ttl = RETENTION_SECONDS[lead.kind];
return env.LEADS.put(key, JSON.stringify(lead), {
...(ttl ? { expirationTtl: ttl } : {}),
metadata: leadMetadata(lead),
});
};
The copy must expire too
KV is my inbox, but I also mirror it into SQLite on my homelab as a backup. A backup that keeps leads after KV expired them quietly breaks the 24 months the policy promises.
So the mirror treats KV as the source of truth, retention included:
- A lead that disappears from KV (expired or deleted) is marked as gone and purged locally after a 30-day grace period.
- Independently, anything older than its kind’s retention is purged locally even if KV still had it.
- Deleted rows are overwritten (
PRAGMA secure_delete=ON), and dated backup files rotate out after 14 days.
The grace period exists for a different failure: if a pull ever sees a wrong or empty namespace, I don’t want it to wipe the mirror. A separate brake refuses to mark more than half the known leads as gone in a single run.
Takeaways
- Put retention where the data lives: a KV
expirationTtlset at write time enforces the policy without a job to maintain. - Retention follows the legal basis — enquiries expire, subscriptions last until withdrawn.
- Route every write through one helper so a rewrite never drops the TTL.
- Backups and mirrors are copies of personal data: they need the same retention, plus a brake against mass deletion.