<?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: MemeChatAI</title>
    <description>The latest articles on DEV Community by MemeChatAI (@memechatai).</description>
    <link>https://dev.to/memechatai</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%2F3968915%2F9a2e865d-6b98-4f7c-ba20-2ade81946c9b.png</url>
      <title>DEV Community: MemeChatAI</title>
      <link>https://dev.to/memechatai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/memechatai"/>
    <language>en</language>
    <item>
      <title>Real-time subscriptions with Firebase and React Native</title>
      <dc:creator>MemeChatAI</dc:creator>
      <pubDate>Mon, 08 Jun 2026 18:21:57 +0000</pubDate>
      <link>https://dev.to/memechatai/real-time-subscriptions-with-firebase-and-react-native-232e</link>
      <guid>https://dev.to/memechatai/real-time-subscriptions-with-firebase-and-react-native-232e</guid>
      <description>&lt;p&gt;Every chat app works fine until two people use it at once. You send a message, it saves, and the other person sees nothing until they close the screen and open it again. So you add a refresh button. Then a timer that re-fetches every few seconds. Now you're hammering the database, burning reads, and the conversation still feels a half second behind real life.&lt;/p&gt;

&lt;p&gt;We hit this building &lt;a href="https://meme-chat-ai.com" rel="noopener noreferrer"&gt;Meme Chat AI&lt;/a&gt;, an app where you trade memes back and forth with a bot. Messages were a read you triggered, so the screen only knew what was true the last time you asked. Firestore's real-time listeners are how we stopped asking and let the data come to us.&lt;/p&gt;

&lt;h2&gt;
  
  
  What onSnapshot actually does
&lt;/h2&gt;

&lt;p&gt;A normal Firestore read with &lt;code&gt;get()&lt;/code&gt; gives you the data once, like taking a photo. &lt;code&gt;onSnapshot&lt;/code&gt; opens a live connection instead. You hand it a query, and Firestore calls you back the moment anything matching that query changes, with the new state already in hand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;firestore&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chats&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;createdAt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;asc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="nf"&gt;setMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That callback fires on the first load, and then again on every new message, every edit, every delete. You stop writing fetch logic and start reacting to changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into a component
&lt;/h2&gt;

&lt;p&gt;The listener has to live for as long as the screen does and then go away cleanly. In a component that means opening it in &lt;code&gt;useEffect&lt;/code&gt; and returning the unsubscribe function so React tears it down on unmount.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unsubscribe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;firestore&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chats&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;createdAt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;asc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;})));&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;unsubscribe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;onSnapshot&lt;/code&gt; hands you back its own unsubscribe function, so returning it from the effect is the whole cleanup story. When &lt;code&gt;chatId&lt;/code&gt; changes, React runs the cleanup and opens a fresh listener for the new chat.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug you will write at least once
&lt;/h2&gt;

&lt;p&gt;Forget that &lt;code&gt;return unsubscribe&lt;/code&gt; and the listener keeps running after the screen is gone. Switch chats a few times and you have four listeners alive at once, all calling &lt;code&gt;setMessages&lt;/code&gt; on a component that no longer exists. It shows up as memory creeping, duplicate updates, and the occasional warning about setting state on something unmounted. The fix is always the same line you skipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writes feel instant for free
&lt;/h2&gt;

&lt;p&gt;The part I didn't expect: the sender doesn't wait on the server. Firestore applies your write to the local cache first and fires your own listener immediately, then syncs to the backend in the background. So the person typing sees their message land right away, and the person across the room sees it a moment later when the server confirms. You get optimistic updates without writing a single line of optimistic update code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it bought us
&lt;/h2&gt;

&lt;p&gt;No refresh button. No polling timer quietly draining the read quota. Type on one device and it shows up on another without anyone asking the database whether anything changed. New features that touch messages inherit the live behavior automatically, because they read from the same listener instead of rolling their own fetch.&lt;/p&gt;

&lt;p&gt;None of this is special to a meme app. It's one listener per screen, opened in an effect and cleaned up on the way out, with the cache making your own writes feel instant. The win was deciding the screen should react to the data instead of going out and asking for it.&lt;/p&gt;

&lt;p&gt;If you want to see the live updates in a shipped app, Meme Chat AI is &lt;a href="https://apps.apple.com/us/app/meme-chat-ai-brainrot-bot/id6774211629" rel="noopener noreferrer"&gt;on the App Store&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>reactnative</category>
      <category>firestore</category>
      <category>vibecoding</category>
    </item>
    <item>
      <title>Theming a React Native app in one place with NativeWind</title>
      <dc:creator>MemeChatAI</dc:creator>
      <pubDate>Mon, 08 Jun 2026 14:49:34 +0000</pubDate>
      <link>https://dev.to/memechatai/theming-a-react-native-app-in-one-place-with-nativewind-5f0f</link>
      <guid>https://dev.to/memechatai/theming-a-react-native-app-in-one-place-with-nativewind-5f0f</guid>
      <description>&lt;p&gt;Every app starts with one brand color in one file. Then there are nine copies of &lt;code&gt;#6C4DFF&lt;/code&gt; scattered across buttons, a header, a loading spinner, and a settings row nobody has opened in months. The day someone asks to nudge the brand a little warmer, you are grepping for hex codes and hoping you caught them all.&lt;/p&gt;

&lt;p&gt;We hit this building &lt;a href="https://meme-chat-ai.com/" rel="noopener noreferrer"&gt;Meme Chat AI&lt;/a&gt;. Styles were defined per component, so the design lived in forty &lt;code&gt;StyleSheet.create&lt;/code&gt; blocks instead of in one place. NativeWind is how we pulled it back into a single source.&lt;/p&gt;

&lt;h2&gt;
  
  
  What NativeWind actually is
&lt;/h2&gt;

&lt;p&gt;It's Tailwind for React Native. You write utility classes in a &lt;code&gt;className&lt;/code&gt; prop and they compile to native styles, no runtime stylesheet objects to maintain by hand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"bg-primary rounded-2xl px-4 py-3"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-on-primary font-semibold"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Send&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the surface change. The part that mattered more for us was where those names like &lt;code&gt;bg-primary&lt;/code&gt; come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  One config, every screen
&lt;/h2&gt;

&lt;p&gt;The token names resolve from a single Tailwind config. Colors, spacing, radius, and font sizes all live there, and every component reads the same definitions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tailwind.config.js&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;theme&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#6C4DFF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;on-primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#FFFFFF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#0E0E12&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;muted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#9A9AA8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;borderRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;20px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;bg-primary&lt;/code&gt; means the same purple in every file. When that purple changes, it changes in one line and the whole app moves with it. No component owns its own copy of the brand, so there is nothing to hunt down later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dark mode stops being a project
&lt;/h2&gt;

&lt;p&gt;Because the theme is centralized, light and dark are two values of the same token instead of two parallel stylesheets. You mark the variant inline and NativeWind picks the right one from the system setting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"bg-white dark:bg-surface"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-black dark:text-white"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Memes incoming&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no theme-switch plumbing threaded through every component and no second set of styles to keep in sync with the first. The thing that usually turns into a multi-day retrofit became a prop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it bought us
&lt;/h2&gt;

&lt;p&gt;Design changes that used to touch dozens of files now touch the config. New screens inherit the brand for free because they're built from the same tokens as everything else, so they look consistent without anyone enforcing it by hand. And the styles sit next to the markup they apply to, which made the components easier to read than a class name pointing off to a stylesheet elsewhere in the file.&lt;/p&gt;

&lt;p&gt;None of this is special to a meme app. It's one config holding the design, utility classes reading from it, and dark mode falling out of the same tokens for free. The win was deciding the theme lives in exactly one place, and letting every screen borrow from it instead of keeping its own copy.&lt;/p&gt;

&lt;p&gt;You can see where it landed in &lt;a href="https://meme-chat-ai.com/" rel="noopener noreferrer"&gt;Meme Chat AI&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>nativewind</category>
      <category>tailwindcss</category>
      <category>themeing</category>
    </item>
    <item>
      <title>Keeping a chat app's token bill flat as conversations grow</title>
      <dc:creator>MemeChatAI</dc:creator>
      <pubDate>Mon, 08 Jun 2026 02:59:37 +0000</pubDate>
      <link>https://dev.to/memechatai/keeping-a-chat-apps-token-bill-flat-as-conversations-grow-44lk</link>
      <guid>https://dev.to/memechatai/keeping-a-chat-apps-token-bill-flat-as-conversations-grow-44lk</guid>
      <description>&lt;p&gt;Every chat feature has the same quiet problem. The first message costs almost nothing. The hundredth message costs a fortune, because by then you are re-sending the entire backlog on every single turn.&lt;/p&gt;

&lt;p&gt;We hit this building &lt;a href="https://meme-chat-ai.com/" rel="noopener noreferrer"&gt;Meme Chat AI&lt;/a&gt;, a chat app where the assistant talks back in memes. A conversation that ran long enough would start sending five, ten, twenty thousand tokens of history with each reply, most of it old and irrelevant to what the user just typed. The model still has to read all of it, you still pay for all of it, and latency creeps up the whole time. Here is what we did about it, and the rate limiter we put in front of it so a single client can't run the bill up on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the fix
&lt;/h2&gt;

&lt;p&gt;The naive options are both bad. You can send the whole transcript (cost grows without bound) or send only the last few messages (the model forgets what happened earlier in the chat). We wanted neither.&lt;/p&gt;

&lt;p&gt;The pattern we landed on is a rolling summary plus a verbatim window. Every prompt looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ stable system / persona prompt ]
[ summary of older turns ]
[ last N turns, word for word ]
[ the current user message ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Older turns don't get dropped. They get folded into a running summary. Recent turns stay exactly as written, because that's the part the model actually needs at full fidelity to answer the next message. Nothing is ever silently lost: a message is either inside the verbatim window or inside the summary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sizing the window by tokens, not message count
&lt;/h2&gt;

&lt;p&gt;Our first version capped the window at a flat message count. That turned out to be the wrong knob.&lt;/p&gt;

&lt;p&gt;A flat count punishes everyone equally, which means it punishes the wrong people. A user on a higher tier has a much larger input budget to work with, so there's no reason to start summarizing their conversation as aggressively as a free user's. But a fixed "keep the last 12 messages" rule did exactly that.&lt;/p&gt;

&lt;p&gt;So we size the window from the token budget instead. Take the plan's input allowance, subtract the fixed overhead that rides along in every prompt (the persona prompt, the summary slot, the current turn), and let the verbatim tail fill most of what's left:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verbatimBudgetTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxInputTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headroom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;maxInputTokens&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;PROMPT_OVERHEAD_TOKENS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headroom&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headroom&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;0.85&lt;/code&gt; is deliberate. Our token count is an estimate, and the provider's count is the one that bills you. Leaving a margin means a small drift between the two estimates never pushes the assembled prompt over the model's actual input limit. There's also a hard ceiling on message count sitting on top of the token budget, purely as a safety bound so a flood of tiny one-word turns can't balloon the prompt or the database reads. In normal use the token budget is what gates; the count cap almost never bites.&lt;/p&gt;

&lt;h2&gt;
  
  
  Truncation is a fallback, not the main mechanism
&lt;/h2&gt;

&lt;p&gt;The summary handles the long-term growth. But assembly still does a final check before anything goes to the model: build the prompt, count it, and if it's somehow over budget, drop the oldest verbatim message and recount. Repeat until it fits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;inputTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;countMessagesTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inputTokens&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;maxInputTokens&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;inputTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;countMessagesTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system prompt, the summary, and the current turn are never candidates for dropping. They're load-bearing. Only the recent-history tail gets trimmed, oldest first. In practice this loop rarely does anything, because the window was already sized to fit. It exists for the edge case where a single pasted wall of text blows past the estimate, and it guarantees we never hand the API a prompt it will reject.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheapest token is the one you stop re-sending
&lt;/h2&gt;

&lt;p&gt;A subtle source of bloat was attachments. When a user sends an image or a GIF, that turn is expensive. The image parts alone can be a couple hundred tokens for one still and several times that for a GIF that gets sampled into frames. The model needs all of that on the turn the image arrives. It does not need it five turns later.&lt;/p&gt;

&lt;p&gt;So once an attachment turn ages into history, we collapse it to a short text placeholder instead of re-sending the pixels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// historical turn that once carried an image&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[User sent an image]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model keeps the thread of "the user showed me something here" without paying the visual token cost on every subsequent turn. Only the current turn is ever allowed to carry real image data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things worth knowing about caching
&lt;/h2&gt;

&lt;p&gt;Two design choices are really about the prompt cache, which most providers now price at a steep discount for tokens they've seen before.&lt;/p&gt;

&lt;p&gt;First, the big static persona prompt goes first and stays byte-identical across every turn and every user. Anything user-specific (their name, their language, any per-user memory) lives in a second block after it, so the expensive cacheable prefix never changes shape from one user to the next.&lt;/p&gt;

&lt;p&gt;Second, the summary only changes when we actually re-summarize. As long as it's stable, the &lt;code&gt;[persona][summary]&lt;/code&gt; prefix stays cacheable between turns. That's also why we don't re-summarize on every message. We batch it: the background summarizer only folds aged-out turns into the summary once enough of them have accumulated, by count or by token volume. Re-summarizing constantly would churn the prefix and throw away cache hits to save a trivial amount of summary length, which is a bad trade.&lt;/p&gt;

&lt;p&gt;The summarizer itself runs as a background job on a cheaper utility model, decoupled from the request path. The user's reply never waits on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limiting, kept boring
&lt;/h2&gt;

&lt;p&gt;Token discipline controls cost per conversation. It does nothing about a client hammering the endpoint. For that we put a small per-IP limiter in front of the streaming function, backed by the database we already had rather than a new piece of infrastructure.&lt;/p&gt;

&lt;p&gt;It's a fixed window: one document per IP per hour, an atomic increment, reject once the count crosses the threshold.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hourBucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;WINDOW_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;docId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;ipKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;hourBucket&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;snap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;()?.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;REQUESTS_PER_HOUR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FieldValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;expireAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Timestamp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromMillis&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;hourBucket&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;WINDOW_MS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details that matter more than the algorithm:&lt;/p&gt;

&lt;p&gt;The IP is hashed before it ever touches storage, so we're not keeping a log of raw client addresses. The bucket carries an &lt;code&gt;expireAt&lt;/code&gt;, so a TTL policy sweeps old documents and the collection doesn't grow forever. And the limiter fails open when there's no IP to key on or when it's running locally, so development against a single localhost address doesn't trip the cap every few minutes. The cost is one read and one write per request, which is cheap next to an LLM call.&lt;/p&gt;

&lt;p&gt;A fixed window has a known weakness: a client can fire a full window's worth of requests at 1:59 and another full window at 2:00. A sliding window or token bucket smooths that out. For our traffic the simple version was the right amount of engineering, and you can always tighten it later without touching anything upstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it bought us
&lt;/h2&gt;

&lt;p&gt;Long conversations stopped getting linearly more expensive. Cost per turn flattened into a band set by the plan's budget instead of climbing with the message count. Older context survives as a summary rather than vanishing, recent context stays exact, and the persona prompt stays cached across turns. The rate limiter caps the blast radius of any single client for the price of one extra read and write.&lt;/p&gt;

&lt;p&gt;None of this is exotic. It's a summary buffer, a token budget, a placeholder for old attachments, and a counter in a database. The useful part was picking the token budget as the thing to scale on, and treating the cache prefix as something to protect rather than an afterthought.&lt;/p&gt;

&lt;p&gt;All of it runs in production behind &lt;a href="https://meme-chat-ai.com/" rel="noopener noreferrer"&gt;Meme Chat AI&lt;/a&gt; if you want to see where it ended up.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>firebase</category>
      <category>performance</category>
    </item>
    <item>
      <title>Adding subscriptions to a React Native meme bot with RevenueCat</title>
      <dc:creator>MemeChatAI</dc:creator>
      <pubDate>Sun, 07 Jun 2026 13:35:01 +0000</pubDate>
      <link>https://dev.to/memechatai/adding-subscriptions-to-a-react-native-meme-bot-with-revenuecat-698</link>
      <guid>https://dev.to/memechatai/adding-subscriptions-to-a-react-native-meme-bot-with-revenuecat-698</guid>
      <description>&lt;p&gt;I built a chatbot that sends you memes, digs up gifs, and roasts you. It's called &lt;a href="https://meme-chat-ai.com/" rel="noopener noreferrer"&gt;MemeChatAI&lt;/a&gt;, and it is, very much on purpose, brainrot. You talk to it, it talks back in the dumbest, funniest way it can manage. That's the whole pitch.&lt;/p&gt;

&lt;p&gt;So here's the thing nobody warns you about when you build a joke app: the joke is free, but charging money for it is a real engineering project. The bot was the fun weekend part. Billing was the part that could actually lose people's money if I got it wrong, and that's the part I want to talk about, because RevenueCat ate most of it for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem I did not want to own
&lt;/h2&gt;

&lt;p&gt;If you've never shipped in-app subscriptions before, here's the short version of what you're signing up for. Apple has its own receipts and renewal rules. Google has different ones. Both need server-side validation if you want to trust anything. Then there's restoring purchases, upgrades, downgrades, free trials, grace periods when someone's card bounces, and the lovely edge case where a user buys on their iPhone and then logs in on an Android tablet and expects their stuff to be there.&lt;/p&gt;

&lt;p&gt;I did the math on building all of that myself and decided I'd rather not. For a roast bot. I'm not proud, but I'm also not sorry.&lt;/p&gt;

&lt;p&gt;RevenueCat is basically the layer that sits between your app and both stores and turns all of that into one SDK and one webhook. That's the elevator pitch, and in my case it mostly held up.&lt;/p&gt;

&lt;h2&gt;
  
  
  One SDK, both stores
&lt;/h2&gt;

&lt;p&gt;The app is React Native on Expo, using &lt;code&gt;react-native-purchases&lt;/code&gt; (v10). The same code path runs on iOS and Android. Apple's StoreKit and Google's Billing Client are both hiding behind one API, so I'm not maintaining two native billing integrations that drift apart over time.&lt;/p&gt;

&lt;p&gt;Prices, products, and the trial all live in the RevenueCat dashboard, not in my code. The app pulls them down as "offerings" and renders whatever comes back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offerings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOfferings&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;offerings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;availablePackages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;purchasePackage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is hardcoded. Pricing localizes per region automatically, and "Restore Purchases" is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restorePurchases&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The quiet win here is that I can change a price or tweak the trial from a dashboard without shipping an app update or sitting in a review queue for two days. Pricing is config now, not code. That alone has saved me more than once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Entitlements are the only source of truth
&lt;/h2&gt;

&lt;p&gt;This is the part that made the rest sane. Instead of my app trying to reason about raw receipts, everything resolves to a single entitlement called &lt;code&gt;pro&lt;/code&gt;. From there I map RevenueCat's products onto four internal tiers: free, basic, plus, and power. That mapping lives in one place and is shared between the client and the server, so the two can't disagree about what a "plus" user is allowed to do.&lt;/p&gt;

&lt;p&gt;There's also a listener that fires the moment anything changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCustomerInfoUpdateListener&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;updatePlanFromEntitlements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Buy, renew, upgrade, cancel: the UI flips over without a refresh or a manual poll. The first time I tested an upgrade and watched the higher tier just appear, I'll admit it felt a little magic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Purchases follow the account, not the phone
&lt;/h2&gt;

&lt;p&gt;On login I tie the RevenueCat customer to the Firebase user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;firebaseUid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one call is why "buy it on your iPhone, use it on your Android" works at all, and why my backend can always trace a purchase back to the right profile. I genuinely did not appreciate how annoying this is to do by hand until I didn't have to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The webhook is where I stopped trusting the client
&lt;/h2&gt;

&lt;p&gt;The client is fast but it's also a liar sometimes. Networks drop. People background the app mid-purchase. So the actual billing record lives server-side.&lt;/p&gt;

&lt;p&gt;A Cloud Function listens for RevenueCat webhook events (&lt;code&gt;INITIAL_PURCHASE&lt;/code&gt;, &lt;code&gt;RENEWAL&lt;/code&gt;, &lt;code&gt;PRODUCT_CHANGE&lt;/code&gt;, &lt;code&gt;EXPIRATION&lt;/code&gt;, &lt;code&gt;CANCELLATION&lt;/code&gt;, &lt;code&gt;BILLING_ISSUE&lt;/code&gt;, &lt;code&gt;TRANSFER&lt;/code&gt;, and friends) and writes the user's plan to the database. RevenueCat is the source of truth, full stop.&lt;/p&gt;

&lt;p&gt;A few things I made sure of, because billing bugs are the worst kind of bug:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Events are idempotent. I dedupe by event ID, so a retry can't apply the same upgrade twice.&lt;/li&gt;
&lt;li&gt;Writes are transactional.&lt;/li&gt;
&lt;li&gt;Sandbox and test events are gated out of production, so my own testing never touches a real user's plan.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The client still does an optimistic "you're upgraded, go enjoy it" write so the app feels instant. But there's a rank guard: a stale client can never downgrade a plan that the webhook authoritatively set. The webhook always gets the last word and reconciles. I lost an afternoon to a race condition before I added that guard, and I'd rather you not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stuff I got for free
&lt;/h2&gt;

&lt;p&gt;A pile of things I would have built badly came included:&lt;/p&gt;

&lt;p&gt;The 7-day free trial is tracked off RevenueCat's trial signals, so I know the difference between someone in trial and someone who actually converted. Upgrades grant the new tier immediately; downgrades wait politely until the current billing cycle ends. Billing issues and grace periods are RevenueCat's problem to manage, and my app just logs and waits. When someone wants to cancel, I hand them the native Apple or Google "manage subscription" screen instead of pretending I should be in the middle of that.&lt;/p&gt;

&lt;p&gt;There's also a test store for local dev, so I can run the whole purchase flow without standing up real App Store and Play products first. For iteration speed that's huge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is there a catch? Sort of.
&lt;/h2&gt;

&lt;p&gt;I want to be honest, because I find "this tool is perfect" posts useless. Handing your billing source-of-truth to a third party is a real dependency, and I thought hard about it. If RevenueCat has a bad day, my purchases have a bad day. There's a cost once you cross their free tier, and you're trading some control for all this convenience.&lt;/p&gt;

&lt;p&gt;For a solo-ish project shipping a meme bot to two stores, that trade was obviously worth it. For a company whose entire business is subscriptions at massive scale, I'd at least want to think about it longer. Your call.&lt;/p&gt;

&lt;p&gt;One accuracy note while I'm here: RevenueCat doesn't see or store your card. Apple and Google run the actual transaction. RevenueCat manages entitlements on top of that. I'd been fuzzy on this myself before I read the docs, so I'm spelling it out.&lt;/p&gt;

&lt;h2&gt;
  
  
  So
&lt;/h2&gt;

&lt;p&gt;The dumb part of MemeChatAI took a weekend. The billing took real care, and most of that care went into the half-page of webhook logic above, not into reimplementing two stores' worth of receipt validation. That's the trade I'd make again.&lt;/p&gt;

&lt;p&gt;If you want to get roasted by a bot, it's on the &lt;a href="https://apps.apple.com/us/app/meme-chat-ai-brainrot-bot/id6774211629" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;, and there's more at &lt;a href="https://meme-chat-ai.com/" rel="noopener noreferrer"&gt;meme-chat-ai.com&lt;/a&gt;. Fair warning: it has no manners. That's a feature.&lt;/p&gt;

</description>
      <category>react</category>
      <category>reactnative</category>
      <category>revenuecat</category>
      <category>ai</category>
    </item>
    <item>
      <title>I built an AI chat app because I was tired of AI sounding like a corporate memo</title>
      <dc:creator>MemeChatAI</dc:creator>
      <pubDate>Sat, 06 Jun 2026 05:00:31 +0000</pubDate>
      <link>https://dev.to/memechatai/i-built-an-ai-chat-app-because-i-was-tired-of-ai-sounding-like-a-corporate-memo-2k1l</link>
      <guid>https://dev.to/memechatai/i-built-an-ai-chat-app-because-i-was-tired-of-ai-sounding-like-a-corporate-memo-2k1l</guid>
      <description>&lt;p&gt;Every AI assistant I tried gave me useful answers but the writing always felt like it came from HR. Four paragraphs, a bullet list, and "I hope this helps!" tacked on at the end. I'd ask something simple and get back a wall of text that read like a policy document.&lt;/p&gt;

&lt;p&gt;The thing is, nobody actually talks like that. I don't, my friends don't, and when I want a quick answer I don't want to feel like I'm reading a memo.So I decided to build one that talks the way people actually do. An assistant that gives you real answers but sounds like a person. Specifically the kind of person who lives in the replies, sends you a meme when you're being dramatic, and still somehow knows the answer to your question.&lt;/p&gt;

&lt;p&gt;That became &lt;a href="https://apps.apple.com/us/app/meme-chat-ai-brainrot-bot/id6774211629" rel="noopener noreferrer"&gt;Meme Chat AI&lt;/a&gt;. The assistant is called Brainrot Bot. It helps you rewrite dry texts so they don't sound flat, explain things without the textbook fog, give honest feedback on half-baked ideas, and find the right caption or angle for whatever you're working on.&lt;/p&gt;

&lt;p&gt;The whole bet behind it was that being useful and having a personality are not a tradeoff. Most AI products act like they are, like the only way to be taken seriously is to sound serious. I don't think that's true, and building this app has mostly confirmed it.&lt;/p&gt;

&lt;p&gt;I'm building this in public and I'll keep posting here about what's working, what broke, and what I picked up along the way. Follow along if you're into the indie app process or just curious where this goes.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>showdev</category>
      <category>sideprojects</category>
    </item>
  </channel>
</rss>
