<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Cesare Bramante</title>
    <description>The latest articles on DEV Community by Cesare Bramante (@cbramante).</description>
    <link>https://dev.to/cbramante</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4150476%2Fe9797120-746b-4a69-911f-85df4de6afcc.png</url>
      <title>DEV Community: Cesare Bramante</title>
      <link>https://dev.to/cbramante</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cbramante"/>
    <language>en</language>
    <item>
      <title>How I made events (and their data) expire in a FlutterFlow + Firebase app</title>
      <dc:creator>Cesare Bramante</dc:creator>
      <pubDate>Thu, 01 Oct 2026 13:37:22 +0000</pubDate>
      <link>https://dev.to/cbramante/how-i-made-events-and-their-data-expire-in-a-flutterflow-firebase-app-16h9</link>
      <guid>https://dev.to/cbramante/how-i-made-events-and-their-data-expire-in-a-flutterflow-firebase-app-16h9</guid>
      <description>&lt;p&gt;Most apps are built to remember. I wanted to build one that forgets on purpose: as little as possible left in the cloud, and what's worth remembering kept only on the phone of the person who lived it.&lt;/p&gt;

&lt;p&gt;I'm an indie developer, and I build orbiTome. You drop an emoji pin on a map, pick a time, invite people from your contacts, and meet up. It's built with FlutterFlow and Firebase, and it's live on iOS and Android in five languages.&lt;/p&gt;

&lt;p&gt;One design rule shaped most of the backend: &lt;strong&gt;an event should leave the cloud shortly after it ends.&lt;/strong&gt; Plans with friends usually live in group chats, and group chats keep everything forever: where you were, when, with whom. I wanted the opposite. The map shows events, not people. When the event is over, its cloud document is deleted together with the guests' replies. Your own history (what you created, what you joined, the photos you took) stays on your phone.&lt;/p&gt;

&lt;p&gt;This post covers how that works in practice, and what I'd tell anyone trying the same thing in FlutterFlow.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. "Expires" is not one number
&lt;/h2&gt;

&lt;p&gt;My first version had one rule: delete the event two hours after it starts. It didn't survive, because events aren't all alike.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When you create it&lt;/th&gt;
&lt;th&gt;Starts&lt;/th&gt;
&lt;th&gt;Expires&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Now / In 1 hour / In 2 hours&lt;/td&gt;
&lt;td&gt;the chosen time&lt;/td&gt;
&lt;td&gt;start + 2 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tonight&lt;/td&gt;
&lt;td&gt;21:00 (if that's past, in 3 hours)&lt;/td&gt;
&lt;td&gt;23:59 the same day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tomorrow night&lt;/td&gt;
&lt;td&gt;21:00 tomorrow&lt;/td&gt;
&lt;td&gt;23:59 tomorrow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pick a date&lt;/td&gt;
&lt;td&gt;chosen date and time&lt;/td&gt;
&lt;td&gt;start + 4 h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wish with a time ("anyone up for a hike Saturday at 9?")&lt;/td&gt;
&lt;td&gt;the proposed time&lt;/td&gt;
&lt;td&gt;at the proposed time, no buffer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wish, no time set&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;7 days after posting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public event from an organization&lt;/td&gt;
&lt;td&gt;set by the source&lt;/td&gt;
&lt;td&gt;end set by the source, or start + 4 h; never more than 30 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On top of the table, two rules apply to every event created in the app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ceiling:&lt;/strong&gt; no event lives more than 6 hours past its start. (A Wish with no date has no start, so it isn't affected.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Floor:&lt;/strong&gt; no expiry falls less than 2 hours from now. A Wish set for 30 minutes from now still lives two hours.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The expiry in the table is a default, not a promise. While the event is running, the app checks the organizer's position every time the app is open on their phone, never in the background. If the organizer is within 50 meters of the pin and the expiry is less than 30 minutes away, the expiry moves to one hour from now, as many times as needed: a meetup that runs long doesn't vanish halfway through. The 6-hour ceiling still applies. It also works the other way: if the organizer checked in and then moves more than 150 meters away, the event ends there, or after a 30-minute grace period if someone else is still on site. Wishes are never extended or closed this way: they're intentions, not places.&lt;/p&gt;

&lt;p&gt;A plan for the evening that starts at 21:00 shouldn't vanish at 23:00 while people are still out. An unanswered Wish, on the other hand, is a loose proposal: it has no reason to exist past the moment it was about. If someone replies and the creator confirms, the Wish becomes a real event and its expiry is recalculated: start + 2 h.&lt;/p&gt;

&lt;p&gt;I didn't design the ceiling and the floor on a whiteboard. Each one came from a bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The floor.&lt;/strong&gt; A Wish created with "Now" had its expiry equal to its start, which was now. It was born expired and disappeared from the map without any error. When I tightened the security rules, the same creation started getting rejected. An expiry that isn't in the future is always a mistake, so now there's a two-hour minimum.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ceiling.&lt;/strong&gt; "Tonight" picked after 21:00 starts the event three hours later, after midnight, and "23:59 of the start day" became 23:59 of the following night: an event starting at 1:30 stayed alive for 22 and a half hours. The 6-hour ceiling closes that case, and other parts of the app (check-in, photos) can rely on it as a rule instead of an assumption.&lt;/p&gt;

&lt;p&gt;A third bug was about languages. The code recognized "Tonight" by comparing the button's label, and in French, Spanish and Romanian it never matched: in those languages every "Tonight" expired after 2 hours. Now the visible label, in any language, is first mapped to a stable code (&lt;code&gt;tonight&lt;/code&gt;, &lt;code&gt;tomorrow&lt;/code&gt;, &lt;code&gt;pick&lt;/code&gt;…), and expiry is computed from the code. With five languages, no logic should depend on a string the user can read.&lt;/p&gt;

&lt;p&gt;The lesson: decide expiry &lt;strong&gt;per type of plan&lt;/strong&gt;, not per database. It also changed how I write the app's copy. I no longer say something "disappears after 2 hours", because it isn't true for every event. What I can say is that an event leaves the cloud shortly after it ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Where the expiry is computed
&lt;/h2&gt;

&lt;p&gt;The expiry timestamp is computed &lt;strong&gt;when the event is created&lt;/strong&gt;, by a small FlutterFlow Custom Function, and stored in the event document as &lt;code&gt;expires_at&lt;/code&gt;. After creation, only two other places change it: turning a Wish into an event (start + 2 h) and the on-site check while the organizer has the app open (extend or close). All three respect the same 6-hour ceiling; everything else only reads the field.&lt;/p&gt;

&lt;p&gt;Computing it at creation keeps everything else simple. The map queries, the cleanup and the invite page all read one field, and none of them re-implements the rules.&lt;/p&gt;

&lt;p&gt;The trade-off is that the client computes the value. The security rules don't recompute it, but they constrain it: on create, &lt;code&gt;expires_at&lt;/code&gt; must exist, must be a timestamp, must be in the future and no more than 400 days out, and the same cap applies on update. The 6-hour ceiling lives only in the client, so a tampered client could keep its own event online for up to 400 days. It's a known limit, and I accepted it because it only affects the person doing it.&lt;/p&gt;

&lt;p&gt;The "must be in the future" check wasn't there from day one, for a specific reason: security rules also apply to the old app versions still installed on people's phones. I only turned it on once the version with the 2-hour floor was effectively the only one in use. Before that, it would have rejected events created by older versions.&lt;/p&gt;

&lt;p&gt;There's one exception. Some events are public and come from city bodies, non-profits and public institutions. They are never created from the app: they come in only through a dedicated web portal, via a private Cloud Function that writes with admin privileges and sets expiry server-side (the end given by the source, or start + 4 h, and never more than 30 days). The security rules reject any client write that tries to create an event in that category or convert an existing one. In short, the client decides expiry only for its own private events.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Who actually deletes the data
&lt;/h2&gt;

&lt;p&gt;Two mechanisms, with two different jobs.&lt;/p&gt;

&lt;p&gt;The first is a &lt;strong&gt;scheduled Cloud Function&lt;/strong&gt; that runs every 60 minutes: it looks for events whose &lt;code&gt;expires_at&lt;/code&gt; has passed and deletes them in batches. In practice an event leaves the cloud within about an hour of expiring.&lt;/p&gt;

&lt;p&gt;The second is a &lt;strong&gt;Firestore TTL policy&lt;/strong&gt; on the same field, as a safety net: if the function misses a run, Firestore deletes the documents anyway. On its own it wasn't enough for me. Google's docs say expired data is "typically deleted within 24 hours", and until then expired documents still show up in queries.&lt;/p&gt;

&lt;p&gt;That's why every query that shows events filters out those whose &lt;code&gt;expires_at&lt;/code&gt; has passed, and the client drops them again before drawing them. &lt;strong&gt;"Expired" and "deleted" are two different states:&lt;/strong&gt; the UI hides an event the moment it expires, even if the backend removes it later.&lt;/p&gt;

&lt;p&gt;What goes away with the event:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guests' replies and scheduled reminders.&lt;/strong&gt; A function triggered on the document's deletion removes them, whatever the cause: the creator deleting the event, the hourly cleanup or TTL. One cleanup point instead of three. Before it existed, replies to deleted events stayed in the database forever, because the security rules (rightly) don't let the creator delete other people's replies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The guest list&lt;/strong&gt;, which is a field of the document itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subcollections&lt;/strong&gt; (for example game data inside an event). Firestore doesn't delete them with the parent document, neither on a normal delete nor through TTL. So each of their documents carries its own expiry, slightly after the event's, with its own TTL policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Files in the cloud:&lt;/strong&gt; none to delete. Event photos and videos are never uploaded (see section 4). Firebase Storage only holds profile pictures.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule I took from this: for every piece of data born around an event, ask who will delete it, and when.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Keeping history without keeping it in the cloud
&lt;/h2&gt;

&lt;p&gt;Events leave the cloud, but people still want to remember them. So the events that involve you are copied into a local SQLite database on your phone, which I call the Vault.&lt;/p&gt;

&lt;p&gt;For each event the Vault keeps the title, emoji, date and time, place name and coordinates, description, organizer's name, your role and your status ("I'm here now", "I was there", "Cancelled by the organizer"). It doesn't keep the list of participants.&lt;/p&gt;

&lt;p&gt;The copy is created right away, not at expiry, because by then the cloud document may already be gone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;when you create an event or a Wish;&lt;/li&gt;
&lt;li&gt;when you reply "I'll be there";&lt;/li&gt;
&lt;li&gt;when you get an invite, but as a provisional copy: if you don't reply, it goes away after the event starts, unless you checked in or added photos. If the organizer cancels before the start, it stays marked "Cancelled by the organizer" for 7 days;&lt;/li&gt;
&lt;li&gt;when you open the app on site, check-in moves your status to "I'm here now" and then to "I was there".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why not Firestore's offline cache? Because the cache mirrors the server: when the event is deleted in the cloud, it disappears from the cache at the next sync. And the cache isn't an archive anyway, Firestore prunes it on its own. I needed the opposite: a copy that outlives the server document.&lt;/p&gt;

&lt;p&gt;The Vault can leave the phone only as an encrypted backup that the user saves wherever they want.&lt;/p&gt;

&lt;p&gt;Event photos and videos follow the same logic. They go into the app's private folder, not the gallery. Before saving, metadata is stripped, location included, and if it can't be stripped the file isn't saved. They're never uploaded, nobody else sees them, and they stay as long as the event stays in your Vault: delete it, or sign out, and they go with it. The encrypted backup doesn't include them.&lt;/p&gt;

&lt;p&gt;Three FlutterFlow + SQLite lessons that cost me hours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;In SQLite queries defined in the FlutterFlow panel, parameters must be written as &lt;code&gt;${name}&lt;/code&gt;.&lt;/strong&gt; FlutterFlow doesn't pass arguments to the query, so &lt;code&gt;?&lt;/code&gt; placeholders stay empty and the &lt;code&gt;WHERE&lt;/code&gt; filters nothing: no error, just wrong results. Careful, though: &lt;code&gt;${name}&lt;/code&gt; inserts the text as-is, it's not a real bound parameter. An apostrophe in a search term breaks the query, so escape it. In Custom Action Dart code, &lt;code&gt;?&lt;/code&gt; with an argument list works normally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrations.&lt;/strong&gt; &lt;code&gt;CREATE TABLE IF NOT EXISTS&lt;/code&gt; doesn't change a table that already exists, and the database bundled with the app only applies to new installs. Users who update need &lt;code&gt;ALTER TABLE&lt;/code&gt; with nullable or defaulted columns, a check that the column doesn't already exist before adding it, and a schema version (I use &lt;code&gt;PRAGMA user_version&lt;/code&gt;) that only moves forward when the step succeeded. Users update from different versions, and a restored backup can carry an older schema than the installed app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Panel queries are for reading.&lt;/strong&gt; FlutterFlow runs the SQLite queries from the panel with &lt;code&gt;rawQuery&lt;/code&gt;, which is meant for reads: an &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt; or &lt;code&gt;DELETE&lt;/code&gt; written there fails silently. Writes belong in a Custom Action, with &lt;code&gt;rawInsert&lt;/code&gt;, &lt;code&gt;rawUpdate&lt;/code&gt; or &lt;code&gt;rawDelete&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An unplanned side effect: reading history from SQLite instead of Firestore takes the Vault's reads to zero. That matters on the free tier, and it matters more as users grow.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What happens to the invite link
&lt;/h2&gt;

&lt;p&gt;Every event has a link that works without the app: whoever opens it sees the emoji, title, organizer, place, time with a live countdown, and how many people confirmed. The page is served by a Cloud Function that asks &lt;code&gt;getJoinPreview&lt;/code&gt; for the data (an endpoint that returns a closed list of fields, one read per open) and rewrites the Open Graph meta tags. WhatsApp, Telegram and iMessage don't run JavaScript, so without that step link previews would only show generic text.&lt;/p&gt;

&lt;p&gt;Anyone with the link sees the preview, even without an account, so the link should only go to the people you want. Inside the app, the event stays visible only to the organizer's contacts or, in Ghost mode, only to the invited guests.&lt;/p&gt;

&lt;p&gt;After expiry:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;in the app, opening the link of an expired event shows "This event has ended." and a "Propose again" button that starts a new event from it;&lt;/li&gt;
&lt;li&gt;on the web, between expiry and deletion (about an hour at most), new previews say "This event has ended." instead of organizer and place; the title with the date stays;&lt;/li&gt;
&lt;li&gt;after deletion, &lt;code&gt;getJoinPreview&lt;/code&gt; answers that the event doesn't exist and the link shows orbiTome's generic invite page, with no event data. A CDN-cached copy can survive two minutes at most.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One last thing deserves a check in any app like this: a link preview already pasted into a group chat keeps showing title, date, organizer and place, because the chat stored the preview, not your server. You can't take back what's already been shared. You can only stop serving it, and decide from the start what goes into the preview.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. What I'd tell someone starting today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Decide expiry per type of plan, write your copy to match, and don't promise a fixed number of hours.&lt;/li&gt;
&lt;li&gt;Store one expiry timestamp at creation, with a ceiling and a floor. Keep the places that can change it few, make them all respect the same ceiling, and let everything else just read it.&lt;/li&gt;
&lt;li&gt;Compute on codes, not on translated labels.&lt;/li&gt;
&lt;li&gt;Hide data on expiry, delete it later, and filter in every query. Firestore TTL is a great safety net, but if latency matters, pair it with a scheduled cleanup.&lt;/li&gt;
&lt;li&gt;For every piece of data born around the event (replies, reminders, subcollections), decide who deletes it.&lt;/li&gt;
&lt;li&gt;Tighten security rules only when the client version that respects them is the one almost everyone runs.&lt;/li&gt;
&lt;li&gt;If history matters, copy it to the device when it's born, not when it's about to disappear.&lt;/li&gt;
&lt;li&gt;Check what shared links show once the data is gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Building an app that forgets turned out to be harder than building one that remembers. But it changed how I explain the product: "the event ends, and so does its data" is something people get in one sentence.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building orbiTome solo, with FlutterFlow + Firebase. If you're working on something similar, I'd love to compare notes in the comments. More at &lt;a href="https://www.orbitome.com" rel="noopener noreferrer"&gt;orbitome.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>firebase</category>
      <category>mobile</category>
      <category>database</category>
    </item>
  </channel>
</rss>
