How Instagram expires stories at exactly 24 hours
How Instagram schedules story expiry using time-to-live fields, delayed job queues, and CDN cache invalidation to remove content reliably at the 24-hour mark.
The Problem Statement
Interviewer: "Instagram stories disappear after 24 hours. Walk me through how that actually works at the systems level. How does Instagram know when to expire a story, how does it actually remove the content, and what guarantees does it provide around timing?"
This question tests more than it first appears. On the surface it sounds like a simple scheduled-delete problem. Underneath it, the interviewer is probing whether you understand the difference between logical expiry and physical deletion, how you schedule tens of thousands of events per second reliably, and why CDN cache invalidation makes the problem significantly harder than it looks.
A strong answer covers the TTL data model, the delayed job architecture, the two-phase expiry pattern, and at least one nuanced failure mode. A weak answer says "just run a cron job every hour." I have seen that answer from candidates at every level, and it fails to demonstrate systems thinking.
The question is also a proxy for how you think about ephemeral data in general. The same pattern applies to Snapchat messages, disappearing messages in iMessage, session tokens, one-time passwords, and rate-limit windows. Nail this answer and you have a reusable mental model for a whole category of systems problems.
Clarifying the Scenario
You: "Before I start, a few clarifying questions."
You: "When you say 'disappear after 24 hours,' do you mean they disappear from the viewer's feed, from the creator's archive, or from Instagram's storage entirely?"
Interviewer: "Start with disappearing from the feed. We can discuss the storage lifecycle after."
You: "Got it. And are we talking about the 24-hour window being exact to the second, or is a few seconds of drift acceptable?"
Interviewer: "Good question. What do you think?"
You: "I would assume seconds of drift are fine. If a story expires at 2:04:13 PM and it actually disappears at 2:04:17 PM, no user can perceive that. The constraint is probably 'within a few seconds,' not 'exactly at the millisecond.'"
Interviewer: "That is correct. Proceed."
You: "And when the story expires, does the media need to be deleted from disk immediately, or can that happen asynchronously on a different timeline?"
Interviewer: "Assume media deletion can happen asynchronously. Focus on when the story stops being visible."
You: "One more: should I also think about the CDN layer? Stories are media-heavy, so the image and video files are almost certainly served from edge caches. Even after the database says the story is expired, a CDN edge might keep serving the media file."
Interviewer: "Yes, exactly the kind of detail I want to see you address."
You: "OK. I will cover four things: the TTL data model, the soft expiry path that makes stories invisible instantly, the hard expiry path that actually frees storage, and the CDN cache invalidation problem that makes this harder than it looks."
The clarifying question about CDN is the one most candidates skip. Raising it unprompted signals that you think end-to-end, not just about the DB layer in isolation.
My Approach
I break this into four areas:
- The TTL data model: How the
expires_atfield makes soft expiry instant and cheap - The delayed job queue: How Instagram schedules roughly 6,000 expiry events per second reliably
- CDN cache invalidation: Why the CDN still serving stale content after DB expiry is a real problem
- Logical vs physical expiry: The gap between "story is invisible" and "bytes are deleted from disk"
The core insight is that Instagram uses a two-phase expiry model. Phase one (soft expiry) is nearly free: a single indexed field on the story record makes the story invisible immediately when its TTL passes. Phase two (hard expiry) is expensive and runs asynchronously: media files get deleted from blob storage, CDN caches get purged, viewer lists get cleaned up, and cascading references get resolved.
Think of it like food in a restaurant kitchen. The "best by" label is stamped at creation (that is the expires_at field). When a chef checks whether an ingredient is usable, they read the label (that is the query filter). But actually throwing out the container, washing the shelf, and re-ordering stock is a separate background process that happens at whatever pace the kitchen staff can manage.
The data model
The story table has a simple schema. The critical fields for expiry are created_at and expires_at. Everything else is standard content metadata:
CREATE TABLE stories (
story_id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
media_url TEXT NOT NULL,
created_at BIGINT NOT NULL, -- Unix timestamp, set by server
expires_at BIGINT NOT NULL, -- created_at + 86400, indexed
status VARCHAR(16) NOT NULL DEFAULT 'active',
CONSTRAINT chk_expires CHECK (expires_at = created_at + 86400)
);
CREATE INDEX idx_stories_user_expiry ON stories (user_id, expires_at);
The composite index on (user_id, expires_at) is what makes feed queries fast. A query like SELECT * FROM stories WHERE user_id = ? AND expires_at > ? hits the index directly, scanning only the stories for this specific user that have not yet expired. No stories older than 24 hours are ever touched by a feed query.
The constraint expires_at = created_at + 86400 is optional but useful. It prevents application bugs from creating stories with wildly incorrect expiry times. If you want variable-length stories (6 hours, 48 hours), you drop the constraint and validate at the application layer instead.
Numbers at a glance
Having these numbers ready when you answer keeps your response grounded and signals that you have thought about production scale, not just the architecture diagram.
| Metric | Approximate value |
|---|---|
| Stories posted per day | ~500 million |
| Average expirations per second | ~5,800 |
| Peak expirations per second | ~10,000+ |
| Redis sorted set entries | ~500 million (~25 GB memory) |
| CDN purge propagation time | 5 seconds to 2 minutes |
| Soft expiry to hard delete gap | Minutes to hours |
| Reconciliation job cadence | Every 2-5 minutes |
| Acceptable timing drift for "exact 24h" | 30-90 seconds (DB), up to 5 minutes (CDN) |
The Architecture
Here is the full story expiry system from upload to deletion.
The flow has two distinct lanes. The creation lane (left side) happens synchronously during upload. The expiry lane (bottom) runs asynchronously on a background worker fleet. The viewer query path (right) is the cheapest part: a single index scan on expires_at.
Step-by-step walkthrough
Let me trace a single story through the full lifecycle.
At T=0 (story posted):
- The mobile client sends a
POST /stories/createrequest with the media file and caption. - The API server writes a row to the stories table:
{ story_id: "abc123", user_id: "U456", created_at: 1712000000, expires_at: 1712086400, status: "active", media_url: "://cdn.../abc123/story.jpg" }. - The API server uploads the image to blob storage and gets back a signed URL.
- The API server calls
ZADD expiry_queue 1712086400 abc123on Redis. This is the only scheduling work done at creation time. It completes in under 1 millisecond. - The response returns to the client. Story is live.
During the 24-hour window:
Any viewer fetching this user's stories runs SELECT * FROM stories WHERE user_id = 'U456' AND expires_at > NOW(). Story abc123 appears in every result until expires_at passes. No background job touch-points are needed to maintain visibility.
At T+86400s (story expires):
- An expiry worker polls Redis:
ZRANGEBYSCORE expiry_queue 0 1712086400 LIMIT 500. Storyabc123appears in the result. - The worker updates the DB:
UPDATE stories SET status = 'expired' WHERE story_id = 'abc123'. The story is now invisible to all feed queries. - The worker removes the job from Redis:
ZREM expiry_queue abc123. - The worker enqueues two async jobs: one for CDN purge, one for blob storage deletion.
Minutes to hours later (hard delete):
- The CDN purge worker calls the CDN invalidation API for
cdn.../abc123/story.jpg. Propagation takes 5-60 seconds. - The hard-delete worker checks: is
abc123referenced by any Highlights? If no, callsDELETE /blob/abc123/story.jpg. If yes, skips the deletion. - The viewer list for story
abc123is deleted by a separate viewer-data cleanup job later.
That is the full story lifecycle. The creation path is synchronous and fast. Everything involving deletion runs asynchronously.
Why expires_at is indexed, not computed
Storing expires_at = created_at + 86400 as a column rather than computing it in queries is a deliberate performance choice. An index on a stored column makes the feed query
WHERE expires_at > NOW() an O(log n) range scan. Computing WHERE created_at > NOW() - 86400 is functionally identical but harder for the query planner to optimize when
the expression mixes columns and functions. Index it at write time and pay once; save the compute on every read.
Deep Dive 1: The Delayed Job Queue
The hardest part of this system is not the expiry logic itself. It is scheduling 500 million expirations per day reliably, at a rate that peaks around 6,000 per second, without bogging down the primary database.
Instagram posts roughly 500 million stories per day. If post creation peaks between 6 PM and 9 PM, then expirations peak between 6 PM and 9 PM the next day. That is a 3-hour window with elevated throughput, not a uniform flat rate. At the absolute peak, you are looking at 500M per day divided by 86,400 seconds per day, which averages roughly 5,800 per second, with peak multipliers pushing that to 10,000 or more.
Running DELETE FROM stories WHERE expires_at < NOW() on a primary database at that rate is a path to a production incident. The database is already handling millions of concurrent queries. A bulk delete scan turns a read-heavy workload into a write-heavy one at exactly the wrong moment.
The solution is a dedicated expiry job system backed by a Redis sorted set.
The sorted set data structure is a natural fit here. The score is the Unix timestamp of the expiry time. Polling for due jobs is a single ZRANGEBYSCORE expiry_queue 0 <current_timestamp> LIMIT 500 command: O(log n + k) where k is the number of results. Removing processed jobs is ZREM, which is O(k log n). The entire poll-and-remove cycle for 500 jobs takes under a millisecond.
At steady state, the sorted set holds roughly 500M entries (one per story, persisted for the 24-hour window before the story expires and the entry is removed). At 50 bytes per entry (story ID plus score), the sorted set uses approximately 25GB of Redis memory. That is well within the range of a single high-memory Redis instance. For extra redundancy, shard the sorted set by user ID hash mod N, distributing the load across multiple Redis nodes without requiring cross-shard coordination.
SQS delay queues have a 15-minute maximum delay
A common mistake is reaching for SQS delayed messages for this use case. SQS supports delivery delays up to 15 minutes. Story expiry is 24 hours away. You cannot use a standard SQS delay attribute to schedule a message 86,400 seconds in the future. You would need a separate scheduler table that materializes SQS messages roughly 15 minutes before they are due. This adds a full extra layer of complexity. A Redis sorted set or a purpose-built scheduler table avoids the problem entirely.
Deep Dive 2: CDN Cache Invalidation at Expiry
This is the part most candidates miss entirely. Even after the database record shows a story as expired and the feed query correctly excludes it, the CDN may still be serving the story media to anyone who has the media URL.
Here is the problem concretely. When you create a story, the media URL looks something like https://cdn.instagram.com/stories/media/abc123/story.jpg. The CDN caches that object at edge nodes globally. The cache TTL on the object might be 1 hour, or 6 hours, or whatever Instagram configured at upload time.
If the story expires at 3:00 PM and the CDN cache TTL is 6 hours, the CDN will keep serving that image until 9:00 PM. Anyone who bookmarked or leaked the URL can still load the image 6 hours after the story "expired." For a privacy-sensitive product like ephemeral stories, this is a real vulnerability.
The CDN purge step introduces latency between soft expiry and full deletion. Purge propagation across a global CDN can take anywhere from 5 seconds to several minutes depending on the provider and the number of edge nodes.
Set CDN TTL short for content with a known expiry date
One way to reduce the invalidation window is to set the CDN cache TTL to a fraction of the content's remaining lifetime. If a story has 2 hours left, set the CDN TTL to 15 minutes. This means the CDN naturally evicts the content close to expiry time, reducing how much you rely on explicit purge calls. The trade-off is more origin hits, which you offset by warming caches aggressively during the story's early life when it is actively being viewed.
Deep Dive 3: The Gap Between Logical and Physical Expiry
"Logical expiry" is when a story stops being visible to viewers. "Physical expiry" is when the bytes are actually deleted from disk. These two events are deliberately separated, and the gap between them can be hours or even days.
Here is why you want that gap. At the moment of soft expiry, you do not yet know whether the story creator saved it to Highlights, whether analytics events are still being attributed to it, or whether a compliance system needs to inspect it before deletion. Deleting bytes the instant the story expires would make all of those downstream processes impossible.
The separation also makes the system more resilient. If the storage deletion job fails (a blob storage API outage, a network partition), the story is still logically expired from the user's perspective. No one can see it. The cleanup can retry later when the service recovers.
The cascade check is important. When a user adds a story to their Highlights, Instagram stores a separate reference to the same media object. If you delete the media 24 hours after the story expires, you break the Highlights version too. The hard delete worker must check for any live references to the media before deleting it.
Viewer lists have their own expiry timeline
The list of users who viewed your story is also ephemeral, but on a different timeline than the story itself. Instagram keeps viewer list data for roughly 48 hours after story creation in some product versions, even though the story itself expires at 24 hours. The viewer list expiry worker is a separate job with a different TTL. Do not assume all metadata expires at the same time as the story.
What happens to a story being viewed at the moment of expiry
A viewer in the middle of watching a story when it expires will usually see it through to the end of their current viewing session. The expiry worker does not interrupt active sessions. The feed query will exclude the story on the next load, but the client has already received the story data and will render it locally. This is the correct behavior: mid-session interruption would be jarring and serves no privacy purpose, since the viewer already has the data in memory.
I have seen candidates confidently say "the story disappears the moment it expires" in interviews, then get tripped up when the interviewer asks "what about a viewer who is watching it right now?" The correct answer is nuanced: the story is no longer discoverable via the feed, but an in-progress session sees it to completion. That distinction is the kind of detail that separates a good answer from a great one.
The Tricky Parts
-
Clock skew across server nodes: Story creation and expiry run on different machines. If the clock on the expiry worker is 5 seconds behind the clock on the creation server, stories can expire 5 seconds late. NTP keeps most servers within 50 milliseconds of each other in practice, but worst-case drift can be several seconds. For a product where "exactly 24 hours" means "within a few seconds," this is acceptable. For a product where timing guarantees are contractual, you would need a distributed clock service like Google's TrueTime.
-
The at-scale batching problem: At 6,000 expirations per second, processing them one at a time is not feasible. Workers must process in batches of hundreds or thousands. But batching means some stories in the batch expire slightly before others, introducing up to
batch_size / processing_rateseconds of timing variation within the batch. Keeping batch processing under 100 milliseconds is the practical goal. -
Cascade deletes across services: A story has references in multiple systems: the main stories table, the viewer-analytics service, the push notification service (for "so-and-so posted a story" notifications), and the search index. Each service needs to clean up its own references when a story expires. This is typically handled by publishing a
story.expiredevent to an event bus, letting each downstream service consume it at its own pace. -
Monotonic clock concerns: Expiry workers use wall clock time to decide what to process next. If a worker's system clock jumps backward (NTP correction), it might re-process already-expired items. If it jumps forward, it might skip items. Workers should persist the last-processed timestamp and never go backward, regardless of what the system clock says.
-
Stories during daylight saving time transitions:
expires_atis stored as UTC Unix timestamps, so DST transitions do not affect the server-side logic at all. But the display of expiry time in the app ("your story expires in 2 hours") can show the wrong time if the client converts UTC to local time without accounting for DST changes. The server is the source of truth; the client is just formatting for display. -
What "exactly 24 hours" actually means in practice: No production system expires stories at the precise millisecond. There are several layers of imprecision, each adding seconds of drift. The Redis poll runs every 5 seconds. The batch size means stories processed at the end of a batch expire slightly later than ones at the start. DB replication lag means read replicas may serve a story for a second or two after the primary updates it. CDN propagation adds another 5-60 seconds. In practice, "exactly 24 hours" means "within 30-90 seconds of the 24-hour mark" for the DB/feed layer and "within 5 minutes" for the CDN layer. That is entirely acceptable for a feature where no user has a stopwatch running.
-
Re-try semantics for failed expiry jobs: The Redis sorted set does not automatically retry failed jobs. If a worker crashes mid-batch after
ZREM-ing a story but before updating the DB, the story stays active in the DB but is gone from the scheduler. The story will remain visible indefinitely unless there is a separate reconciliation job that periodically scans for stories whereexpires_at < NOW() AND status = 'active'. This reconciliation job can run infrequently (every few minutes) since the main sorted set handles 99.9% of cases correctly. Reconciliation is the safety net, not the primary path.
What Most People Get Wrong
| Mistake | What they say | Why it is wrong | What to say instead |
|---|---|---|---|
| Cron job thinking | "Run a cron every hour to delete expired stories" | Hourly granularity means stories can be visible 59 minutes past expiry; full-table scan at scale destroys DB throughput | "Use a sorted set queue polled every few seconds, processing small batches" |
| Conflating soft and hard expiry | "When the story expires, delete it" | Immediate deletion blocks Highlights, analytics, compliance, and makes the deletion non-retryable | "Soft expire first (instant, cheap), hard delete asynchronously (minutes to hours)" |
| Ignoring CDN staleness | "After we expire it in the DB, it is gone" | CDN may keep serving the media URL for hours after DB expiry | "Need explicit CDN purge or very short CDN TTLs as a backstop" |
| Timezone confusion | "Users in different timezones get different expiry times" | Server stores UTC, expiry is always 86400 seconds from creation regardless of timezone | "The server uses UTC timestamps; timezone only affects how the countdown is displayed in the app" |
| Underestimating scale | "Just run a batch job once a day" | 500M stories means roughly 6,000 expirations per second at peak; a once-daily batch would process hundreds of millions of records in one run | "Continuous incremental processing with a sorted set, rate-limited to avoid DB saturation" |
How I Would Communicate This in an Interview
Here is how I would actually deliver this answer:
"Instagram stories expire at 24 hours using what I call a two-phase expiry model. At creation time, we stamp the story with an expires_at field set to now + 86400 seconds. Every feed query filters on WHERE expires_at > NOW(), so stories become invisible the instant their TTL passes, with no separate job needed for that visibility change.
For the cleanup side, we cannot just delete records inline because at Instagram scale that is roughly 6,000 expirations per second at peak. Instead, at creation time we enqueue the story ID into a Redis sorted set with the expiry timestamp as the score. Workers poll this set every few seconds, pulling batches of stories whose expiry time has passed.
The part most people miss is CDN cache invalidation. Even after the DB shows the story as expired, the CDN may still be serving the media URL from cache. So the expiry worker also fires a CDN purge job after the soft expiry. The media is fully gone from all edge nodes within about 30-60 seconds of the story expiry time.
The last nuance is the difference between logical expiry and physical deletion. I would let story media sit in blob storage for a few hours after expiry before deleting bytes, because the story might be referenced in Highlights and because analytics and compliance systems need time to process the data. Soft expiry is the user-facing event. Hard deletion is the storage-cleanup event. Separating them keeps the system robust to downstream failures."
That answer covers all four components in under 90 seconds. I would pause after the CDN cache point and ask if they want me to go deeper on any of the four areas. That gives the interviewer control and signals organized thinking.
A follow-up the interviewer will almost certainly ask: "What if the expiry worker goes down for an hour?" The answer is: stories that should have expired remain visible for up to an hour, but the query filter is still the authoritative correctness guarantee. The worker is the cleanup mechanism, not the correctness mechanism. Point this out proactively and you will stand out from every candidate who only discussed the happy path.
One phrase that elevates every answer about TTL systems
Say the words "soft expiry" and "hard expiry" explicitly in your interview answer and define them: soft expiry is "when the content stops being visible," hard expiry is "when the bytes are deleted." This two-term framework organizes your entire answer and makes it clear you think in phases rather than in single-step operations. Most candidates skip this framing and their answer sounds ad hoc even when the technical content is correct.
Interview Cheat Sheet
expires_at = created_at + 86400: The TTL is a stored column, not computed. Index it. Feed queries useWHERE expires_at > NOW()for an O(log n) range scan.- Soft expiry is instant: The
expires_atindex filter makes stories invisible the moment their TTL passes. No background job is needed for the visibility change itself. - Hard expiry is async: Blob storage deletion, CDN purge, viewer list cleanup, and analytics events all happen on a separate async timeline after soft expiry.
- Redis sorted set for scheduling: Score = expiry Unix timestamp.
ZRANGEBYSCORE 0 now() LIMIT 500to get due jobs. O(log n) poll, O(k log n) removal. - SQS delay queues max at 15 minutes: Cannot use SQS delay attributes to schedule a 24-hour future event. Use a Redis sorted set or a scheduler table instead.
- CDN staleness is the key gotcha: DB expiry does not equal CDN expiry. Need explicit purge calls plus short CDN TTLs (15-30 min) as a backstop.
- 500M stories/day = roughly 6,000 expirations/second at peak: Must process in batches with rate limiting. Never do a synchronous full-table scan on the primary DB.
- Stories in Highlights skip media deletion: The cascade check must verify no live references exist before deleting blob storage objects.
- Viewer lists have their own TTL (typically 48 hours): Separate expiry job for viewer data, different timeline than story expiry.
- Clock skew is tolerable here: NTP keeps servers within 50ms; "exactly 24 hours" in practice means "within a few seconds." That is acceptable for this use case.
Likely follow-up questions
| Interviewer asks | Strong answer |
|---|---|
| "What if the expiry worker goes down?" | "Stories remain visible past 24h but the feed query filter is still the authoritative guard. Add a reconciliation job that scans for expires_at < NOW() AND status = active every few minutes as a safety net." |
| "How do you handle stories added to Highlights?" | "Cascade check before hard delete: if highlight_items has a reference to the media URL, skip blob deletion. Or copy media to a Highlights bucket at pin-time, then the original can always be deleted safely." |
| "What is the difference between soft and hard expiry?" | "Soft expiry = story stops being visible (instant, free, driven by the query filter). Hard expiry = bytes are deleted from storage (async, minutes to hours later, requires cascade checks)." |
| "How would you debug a story that is still visible 2 hours after it should have expired?" | "Check in order: (1) DB status and expires_at value, (2) Redis sorted set still has the story_id, (3) expiry worker logs for that time window, (4) is the CDN serving the media URL directly, bypassing the feed filter?" |
Test Your Understanding
Quick Recap
- Every story gets an
expires_atfield set tocreated_at + 86400seconds at creation time. This single indexed column powers soft expiry with no separate job needed for the visibility change. - Feed queries use
WHERE expires_at > NOW()to exclude expired stories instantly. The story is logically invisible the moment its TTL passes. - A Redis sorted set (scored by expiry timestamp) holds pending expiry jobs. Workers poll with
ZRANGEBYSCOREevery few seconds and process batches of roughly 500 at a time. - SQS delay queues are not suitable for 24-hour scheduling; they cap at 15 minutes. Use a sorted set or a dedicated scheduler service instead.
- CDN cache invalidation is the most commonly missed step. Soft expiry in the DB does not remove content from CDN edge caches. Explicit purge calls are required.
- Use short CDN TTLs (15-30 minutes) as a backstop so that even if the purge job fails, stale content disappears within 30 minutes.
- Hard deletion (blob storage, viewer lists, cascade checks) runs asynchronously after soft expiry, often minutes to hours later, to allow Highlights, analytics, and compliance systems to operate.
- At 500M stories per day, expiry peaks around 6,000 events per second. Rate-limit the expiry workers and include graceful backlog-draining logic for post-downtime catch-up.
- A reconciliation job (runs every few minutes) catches stories that the main expiry worker missed due to crashes or bugs. The reconciliation job scans
WHERE expires_at < NOW() AND status = 'active'. It should be infrequent; the sorted set handles 99.9% of cases. - "Exactly 24 hours" in practice means within 30-90 seconds for the DB/feed layer and within several minutes for the CDN layer. No user has a stopwatch. That timing window is acceptable for every ephemeral content product I am aware of.
Related Concepts
- TTL-based caching patterns: The same
expires_atcolumn pattern applies to cached search results, session tokens, rate-limit counters, and verification codes. Any datum with a known lifetime benefits from a storedexpires_atrather than a computed one. The pattern also applies to magic-link email tokens, OAuth authorization codes, and device verification challenges. - Delayed job queues: Redis sorted sets for scheduling is a pattern used across the industry (Sidekiq, Resque, Celery all use variants of this). Understanding why sorted sets are the right data structure (O(log n) insert and O(log n + k) range query) transfers to any time-based scheduling problem. The alternative, a scheduled tasks table in PostgreSQL, works well at smaller scale but requires careful index management as the table grows.
- CDN cache invalidation: The story expiry purge problem is a specific instance of the general CDN invalidation problem. The same tradeoffs (natural TTL vs explicit purge, propagation latency, cost at scale) apply to product launches, content updates, and any time-sensitive cache invalidation. At large CDN providers, bulk invalidation APIs let you purge thousands of URLs in a single API call.
- Soft delete vs hard delete: The two-phase expiry model (logical expiry first, physical deletion later) is a widely used pattern in data engineering. Event sourcing, data retention policies, GDPR deletion workflows, and database vacuuming all follow the same separation. In PostgreSQL, the
VACUUMprocess is exactly this: rows are soft-deleted by the transaction that removes them, and hard-deleted by the background vacuumer later. - Event-driven cascade cleanup: Publishing a
story.expiredevent to a bus so downstream services (analytics, notifications, search index) can clean up their own references is a textbook example of the event-driven architecture pattern. The expiry worker does not need to know about every downstream dependency; it just publishes the event. The downstream services own their own cleanup logic and can evolve independently.