Why We Retire Old Chat Conversations After 90 Days (And How to Keep the Ones That Matter)
How conversation retention works on Milly Chat: 90-day rolling retention for lower tiers, save/flag exemption, R2 cold storage, and unlimited retention on Core+.
Every chat conversation a Shopify merchant's AI widget handles is a row in a database — visitor messages, assistant replies, recommended products, captured leads. On a moderately busy store, that's thousands of rows a week. On a busy store, hundreds of thousands. Without retention, every old transcript stays forever, the replay tool gets sluggish under the weight, and the storage bill grows without bound.
Conversation retention is the answer, but it has to be the right shape. Aggressive deletion loses the conversations that actually mattered. No deletion at all costs money and degrades every tool that scans transcripts. We landed on a 90-day-rolling default for lower tiers, with explicit exemptions for the conversations a merchant chooses to keep — and unlimited retention as the Core+ default.
Why retention exists
Two real costs sit on top of the conversations table:
- Storage cost: each conversation row carries the full message array as JSONB. Multiply by traffic, scale by months, the table grows linearly without bound. Postgres handles it fine for a long time, but eventually queries that scan the table — replay search, gap analysis, certain analytics rollups — start to slow, and the storage line on the bill stops being negligible.
- Signal-to-noise on replay: replay is a merchant tool. They open it to find a specific conversation — "the wholesale buyer who asked about bulk pricing last Tuesday" — or to QA the AI's recent behavior. Both jobs degrade when there are nine months of low-signal conversations cluttering the search space. Recent + interesting is more useful than complete.
These costs are tier-shaped. A Starter merchant on a smaller plan needs storage costs to scale with their pricing. Core/Enterprise merchants paying for higher capacity already cover storage at a different unit economic — they shouldn't face arbitrary deletion of the conversations they may need to audit later.
The 90-day rolling default
On Starter and Essentials tiers, conversations expire 90 days after creation. The mechanism is a nightly cron in milly-cloud-services (retentionPurge.js) that does three things, in order:
- Stamp
expires_aton conversations approaching 90 days (specifically, those older than 83 days that don't have an expiry yet). The 7-day window before deletion is when the replay UI starts showing a clock icon — the merchant has a heads-up. - Export expired conversations to R2 cold storage before deleting them. Each store gets a JSONL archive of conversations whose
expires_athas passed, including messages, page URL, matched conditional rule, created_at, and updated_at. - Delete the expired conversations from the primary DB.
The export-then-delete order matters. The conversation isn't gone the moment the cron runs — it's in the cold archive first, then the row drops from the live table. Recovery is possible from the archive if a merchant ever needs it, even though the live replay tool can no longer surface it.
Save / flag exemption
The 90-day timer doesn't apply to conversations the merchant marks as worth keeping. Saving a conversation sets a saved_at timestamp, and the cron skips any row with saved_at IS NULL excluded. Saved conversations live indefinitely.
The save action is one click on the replay surface. Whatever the conversation contains — a successful B2B inquiry that needs follow-up, an ATC funnel that wants closer inspection, a problematic AI response that's worth keeping for QA — the merchant can keep it without thinking about retention windows. A saved-conversations filter on the replay surface makes the kept set explicit: the merchant always knows what they've flagged for indefinite keep.
Unsaving a conversation removes the exemption — the cron catches up on the next nightly run and stamps the expiry from the original creation date.
The replay surface, retention-aware
Retention is invisible if it's a silent purge — the merchant tries to find a 4-month-old conversation, doesn't, wonders if the search is broken. Surfacing the retention state explicitly avoids that:
- Clock icon on conversations approaching expiry — visible signal that a conversation has < 7 days left unless saved
- Retention banner on the replay page that explains the active policy and how to save
- Saved filter in the replay list to view only the conversations the merchant has explicitly kept
The visual treatment matters more than it sounds. A merchant who knows the policy can act on it; a merchant surprised by an invisible deletion blames the tool.
Core and Enterprise: no auto-expiration
The cron explicitly excludes Core and Enterprise tiers — its PURGE_TIERS list contains only starter and essentials. Conversations on Core+ stores never have expires_at stamped, never get exported to cold storage as part of the rolling cron, never get deleted automatically.
This is the intentional shape of the tier ladder. The merchants paying for Core or Enterprise are typically the ones with the highest LTV customers, the longest consideration cycles, the most need for multi-month conversation history. Auto-deletion of any of that would be a downgrade.
Analytics roll-ups are computed separately from the conversations table — daily aggregates land in dedicated rollup tables (analytics_daily, widget_attribution_daily, weekly recap views) so historical metrics stay intact even on Starter/Essentials stores after the underlying transcripts have rolled off. The retention policy applies to transcripts, not to the numbers derived from them.